feat(frontend): 주소록 화면 · 메일 연동

레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 —
과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다.
디자인 README "8단계", 계약 3절.

리뷰로 고친 것:
- 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다.
- [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만.
- [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다.
- 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다.
- 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId).
- 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다).
- 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:13:20 +09:00
parent 08cf31fbe6
commit e2c41d38bb
65 changed files with 11130 additions and 214 deletions
@@ -0,0 +1,66 @@
<script setup lang="ts">
// 연락처 아바타 — 이름 첫 글자(디자인 원본 Contacts: 목록 32 · 상세 48). 공용 주소는 회색 (neutral)
// 장식이다 — 이름은 옆 글이 읽는다 (aria-hidden)
import { computed } from 'vue'
import { initialOf } from '@/shared/utils/format'
interface Props {
name: string | null
/** 이름이 없을 때 첫 글자를 가져올 글 (주소) */
fallback?: string
size?: 'md' | 'lg'
tone?: 'brand' | 'neutral'
}
const props = withDefaults(defineProps<Props>(), {
fallback: '',
size: 'md',
tone: 'brand',
})
const initial = computed(() => initialOf(props.name, props.fallback))
</script>
<template>
<span
:class="['contact-avatar', `contact-avatar--${size}`, `contact-avatar--${tone}`]"
aria-hidden="true"
>{{ initial }}</span>
</template>
<style scoped>
.contact-avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-round);
font-weight: 600;
}
/* 목록 줄 — 32 · 13px */
.contact-avatar--md {
width: 2rem;
height: 2rem;
font-size: var(--font-size-base);
}
/* 상세 머리 — 48 · 18px */
.contact-avatar--lg {
width: var(--space-48);
height: var(--space-48);
font-size: var(--font-size-title-lg);
}
.contact-avatar--brand {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 공용 주소 — 회색 (디자인 원본 Contacts: #EEF0F3 · #344054) */
.contact-avatar--neutral {
background: var(--color-border-subtle);
color: var(--color-text-strong);
}
</style>
@@ -0,0 +1,576 @@
<script setup lang="ts">
// 주소록 상세 칸 (디자인 원본 Contacts 오른쪽 · README "8단계 › 주소록 화면") — 콘텐츠 열(최대 800)
// - 머리 줄: 내 연락처 [메일 쓰기] [고치기] … [⋯](주소 복사 · 연락처 지우기) / 사내 [메일 쓰기] [내 주소록에 추가]
// - 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소")
// - 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) ·
// 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다. 사내 상세 아래에는 "사내 목록은 메일 서버의 계정에서 옵니다 — …"
// - [메일 쓰기] · 주소 링크는 받는 사람을 채운 새 쓰기 화면 — 주소는 URL 이 아니라 라우터 상태로 (compose-prefill.ts).
// 주소가 없는 연락처의 [메일 쓰기] · 3,000개일 때의 [내 주소록에 추가]는 aria-disabled + 까닭(가린 글 · title — 메뉴의 note 처럼)
// - [고치기]는 그 연락처를 새로 받아 창을 연다(받는 동안 aria-busy — useContactActions 의 loadForEdit). 못 받으면 창을 열지 않는다
// - 고른 것이 없으면 "연락처를 고르세요", 없는 연락처 · 사내 항목(지워짐 · 옛 주소)이면 사전 문구 + [목록으로], 그 밖에 받지 못했으면 사전 문구 + [다시 시도]
// 고정 id — #contact-detail-name · 까닭 #contact-compose-note · #contact-add-mine-note
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import ContactAvatar from '@/components/contacts/ContactAvatar.vue'
import ContactMoreMenu from '@/components/contacts/ContactMoreMenu.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { type ApiError } from '@/composables/useApi'
import { useContactActions } from '@/composables/useContactActions'
import { useContactsDialogs } from '@/composables/useContactsDialogs'
import {
CONTACT_DETAIL_TEXT,
CONTACT_EMAIL_LABEL_TEXT,
CONTACT_PHONE_LABEL_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_PANEL_TEXT,
} from '@/shared/constants/contacts'
import type { Address } from '@/shared/types/mail'
import { composePrefillState } from '@/shared/utils/compose-prefill'
import {
contactDisplayName,
contactOrgLine,
contactSubtitle,
primaryEmail,
} from '@/shared/utils/contacts'
import {
contactsItemLocation,
contactsListLocation,
type ContactsRouteState,
} from '@/shared/utils/contacts-route'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
state: ContactsRouteState
}
const props = defineProps<Props>()
const store = useContactsStore()
const authStore = useAuthStore()
const dialogs = useContactsDialogs()
const actions = useContactActions()
const router = useRouter()
const directoryKind = computed(() =>
props.state.source.kind === 'directory' ? props.state.source.directoryKind : null,
)
const contact = computed(() => (directoryKind.value ? null : store.contactById(props.state.selectedId)))
const entry = computed(() => (directoryKind.value ? store.entryById(props.state.selectedId) : null))
/** 목록에 없는 고른 것을 따로 받은 상태 — 연락처(GET /contacts/{id}) · 사내 항목(GET /directory-entries/{id}) */
const pickedStatus = computed(() => (directoryKind.value ? store.entryDetailStatus : store.detailStatus))
/** 고른 것을 아직 찾는 중 — 목록 · 그것을 받는 중 */
const loading = computed(() => {
if (!props.state.selectedId || contact.value || entry.value) return false
if (store.listStatus === 'loading' || store.listStatus === 'idle') return true
return pickedStatus.value === 'loading' || pickedStatus.value === 'idle'
})
/** 고른 것을 보일 수 없다 — 따로 받기가 실패했을 때(없음 · 장애) */
const failure = computed<ApiError | null>(() => {
if (!props.state.selectedId || contact.value || entry.value || loading.value) return null
if (pickedStatus.value !== 'error') return null
return directoryKind.value ? store.entryDetailError : store.detailError
})
/** 없는 것(RES_001) — 다시 해도 같으니 [목록으로], 그 밖은 [다시 시도] */
const notFound = computed(() => failure.value?.code === 'RES_001')
function composeLocation(addresses: readonly Address[]): RouteLocationRaw {
return { name: 'mail-compose', state: composePrefillState(addresses, authStore.user?.email ?? '') }
}
const contactName = computed(() => (contact.value ? contactDisplayName(contact.value) : ''))
const contactPrimary = computed(() => (contact.value ? primaryEmail(contact.value) : null))
const contactCompose = computed(() =>
contact.value && contactPrimary.value
? composeLocation([{ name: contact.value.name, email: contactPrimary.value }])
: null,
)
const orgLine = computed(() => (contact.value ? contactOrgLine(contact.value) : ''))
const contactGroups = computed(() =>
(contact.value?.groupIds ?? []).flatMap((id) => {
const group = store.groupById(id)
return group ? [group] : []
}),
)
const entryCompose = computed(() =>
entry.value ? composeLocation([{ name: entry.value.name, email: entry.value.email }]) : null,
)
/** 이 연락처를 새로 받는 중 — [고치기]의 바쁜 표시 */
const editLoading = computed(() => Boolean(contact.value) && store.editLoadingId === contact.value?.id)
/** [고치기] — 새로 받은 값으로 창을 연다. 받는 사이 다른 연락처를 골랐으면 열지 않는다 */
async function editContact() {
const current = contact.value
if (!current || editLoading.value) return
const fresh = await actions.loadForEdit(current)
if (fresh && props.state.selectedId === fresh.id) dialogs.openContact(fresh)
}
function addEntryToMine() {
const current = entry.value
if (!current || store.isFull) return
dialogs.openContact(null, { name: current.name, email: current.email, label: 'work' })
}
function leaveToList() {
void router.replace(contactsListLocation(props.state.source, props.state.q))
}
function onFailureAction() {
const id = props.state.selectedId
if (notFound.value) leaveToList()
else if (id && directoryKind.value) void store.openEntry(id)
else if (id) void store.openDetail(id)
}
function groupLocation(groupId: string): RouteLocationRaw {
return contact.value
? contactsItemLocation({ kind: 'group', groupId }, contact.value.id)
: contactsListLocation({ kind: 'group', groupId })
}
</script>
<template>
<div class="contact-detail">
<article
v-if="contact"
class="contact-detail__article"
aria-labelledby="contact-detail-name"
:data-contact-detail="contact.id"
>
<div class="contact-detail__bar">
<div
class="contact-detail__toolbar"
role="group"
:aria-label="CONTACT_DETAIL_TEXT.actions"
>
<MailToolButton
icon="edit"
:label="CONTACT_DETAIL_TEXT.compose"
:to="contactCompose"
:unavailable="!contactCompose"
:describedby="contactCompose ? undefined : 'contact-compose-note'"
:title="contactCompose ? undefined : CONTACT_DETAIL_TEXT.composeNoAddress"
data-contact-compose
/>
<span
v-if="!contactCompose"
id="contact-compose-note"
class="visually-hidden"
>{{ CONTACT_DETAIL_TEXT.composeNoAddress }}</span>
<MailToolButton
icon="edit-square"
:label="CONTACT_DETAIL_TEXT.edit"
:busy="editLoading"
data-contact-edit
@click="editContact"
/>
<ContactMoreMenu
class="contact-detail__more"
:contact="contact"
/>
</div>
</div>
<div class="contact-detail__content">
<div class="contact-detail__head">
<ContactAvatar
:name="contact.name"
:fallback="contactPrimary ?? ''"
size="lg"
/>
<div class="contact-detail__who">
<h2
id="contact-detail-name"
class="contact-detail__name"
>
{{ contactName }}
</h2>
<span class="contact-detail__subtitle">{{ contactSubtitle(contact) }}</span>
</div>
</div>
<dl class="contact-detail__fields">
<template v-if="contact.emails.length > 0">
<dt>{{ CONTACT_DETAIL_TEXT.email }}</dt>
<dd class="contact-detail__list">
<span
v-for="(email, index) in contact.emails"
:key="`${index}-${email.address}`"
class="contact-detail__item"
>
<RouterLink
:to="composeLocation([{ name: contact.name, email: email.address }])"
class="contact-detail__address"
:title="CONTACT_DETAIL_TEXT.composeTo(email.address)"
data-contact-address
>{{ email.address }}</RouterLink>
<span class="contact-detail__kind">{{ CONTACT_EMAIL_LABEL_TEXT[email.label] }}</span>
</span>
</dd>
</template>
<template v-if="contact.phones.length > 0">
<dt>{{ CONTACT_DETAIL_TEXT.phone }}</dt>
<dd class="contact-detail__list">
<span
v-for="(phone, index) in contact.phones"
:key="`${index}-${phone.number}`"
class="contact-detail__item"
>
<span class="contact-detail__number">{{ phone.number }}</span>
<span class="contact-detail__kind">{{ CONTACT_PHONE_LABEL_TEXT[phone.label] }}</span>
</span>
</dd>
</template>
<template v-if="orgLine">
<dt>{{ CONTACT_DETAIL_TEXT.org }}</dt>
<dd>{{ orgLine }}</dd>
</template>
<template v-if="contact.title?.trim()">
<dt>{{ CONTACT_DETAIL_TEXT.title }}</dt>
<dd>{{ contact.title }}</dd>
</template>
<template v-if="contactGroups.length > 0">
<dt>{{ CONTACT_DETAIL_TEXT.groups }}</dt>
<dd class="contact-detail__groups">
<RouterLink
v-for="group in contactGroups"
:key="group.id"
:to="groupLocation(group.id)"
class="contact-detail__group"
:title="CONTACT_DETAIL_TEXT.openGroup(group.name)"
>
{{ group.name }}
</RouterLink>
</dd>
</template>
<template v-if="contact.note?.trim()">
<dt>{{ CONTACT_DETAIL_TEXT.note }}</dt>
<dd class="contact-detail__note">
{{ contact.note }}
</dd>
</template>
</dl>
</div>
</article>
<article
v-else-if="entry"
class="contact-detail__article"
aria-labelledby="contact-detail-name"
:data-directory-detail="entry.id"
>
<div class="contact-detail__bar">
<div
class="contact-detail__toolbar"
role="group"
:aria-label="CONTACT_DETAIL_TEXT.actions"
>
<MailToolButton
icon="edit"
:label="CONTACT_DETAIL_TEXT.compose"
:to="entryCompose"
data-contact-compose
/>
<MailToolButton
icon="plus"
:label="CONTACT_DETAIL_TEXT.addToMine"
:icon-stroke-width="1.9"
:unavailable="store.isFull"
:describedby="store.isFull ? 'contact-add-mine-note' : undefined"
:title="store.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined"
data-contact-add-mine
@click="addEntryToMine"
/>
<span
v-if="store.isFull"
id="contact-add-mine-note"
class="visually-hidden"
>{{ CONTACTS_PANEL_TEXT.fullNote }}</span>
</div>
</div>
<div class="contact-detail__content">
<div class="contact-detail__head">
<ContactAvatar
:name="entry.name"
:fallback="entry.email"
size="lg"
:tone="entry.kind === 'shared' ? 'neutral' : 'brand'"
/>
<div class="contact-detail__who">
<h2
id="contact-detail-name"
class="contact-detail__name"
>
{{ entry.name }}
</h2>
<span class="contact-detail__subtitle">{{
entry.kind === 'shared' ? CONTACT_DETAIL_TEXT.shared : CONTACT_DETAIL_TEXT.person
}}</span>
</div>
</div>
<dl class="contact-detail__fields">
<dt>{{ CONTACT_DETAIL_TEXT.email }}</dt>
<dd class="contact-detail__list">
<span class="contact-detail__item">
<RouterLink
v-if="entryCompose"
:to="entryCompose"
class="contact-detail__address"
:title="CONTACT_DETAIL_TEXT.composeTo(entry.email)"
data-contact-address
>{{ entry.email }}</RouterLink>
</span>
</dd>
</dl>
<p class="contact-detail__hint">
{{ CONTACT_DETAIL_TEXT.directoryHint }}
</p>
</div>
</article>
<div
v-else-if="loading"
class="contact-detail__loading"
aria-busy="true"
:aria-label="CONTACT_DETAIL_TEXT.loading"
>
<span class="contact-detail__bar-skeleton contact-detail__bar-skeleton--title" />
<span class="contact-detail__bar-skeleton" />
<span class="contact-detail__bar-skeleton contact-detail__bar-skeleton--short" />
</div>
<StateMessage
v-else-if="failure"
tone="danger"
icon="alert-circle"
:title="failure.message"
:trace-id="failure.traceId"
fill
>
<button
type="button"
class="contact-detail__leave"
@click="onFailureAction"
>
{{ notFound ? CONTACT_DETAIL_TEXT.leaveToList : CONTACTS_LIST_TEXT.retry }}
</button>
</StateMessage>
<StateMessage
v-else
icon="user"
:title="CONTACT_DETAIL_TEXT.noneTitle"
:hint="CONTACT_DETAIL_TEXT.noneHint"
fill
data-contact-none
/>
</div>
</template>
<style scoped>
.contact-detail {
flex: 999 1 30rem;
min-width: 0;
display: flex;
flex-direction: column;
}
.contact-detail__article {
display: flex;
flex-direction: column;
}
/* 머리 줄 띠 — 칸 전체 폭으로 아래 선, 스크롤해도 위에 남는다 (읽기 화면과 같다) */
.contact-detail__bar {
position: sticky;
top: 0;
z-index: 1;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 단추 줄 — 콘텐츠 열 안 (안쪽 22 + 단추 안쪽 10 = 첫 아이콘이 32px 선) */
.contact-detail__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
max-width: var(--content-max-width);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-24) var(--space-4) var(--content-toolbar-start);
}
/* ⋯ 는 오른쪽 끝 */
.contact-detail__more {
margin-left: auto;
}
/* 콘텐츠 열 — 최대 800 · 위 22 · 좌우 32 · 아래 36 (디자인 원본 Contacts) */
.contact-detail__content {
display: flex;
flex-direction: column;
gap: var(--space-20);
max-width: var(--content-max-width);
padding: var(--space-22) var(--content-inline) var(--content-block-end);
}
.contact-detail__head {
display: flex;
align-items: center;
gap: var(--space-14);
}
.contact-detail__who {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.contact-detail__name {
margin: 0;
font-size: var(--font-size-heading);
line-height: var(--line-height-heading);
font-weight: 650;
letter-spacing: var(--letter-spacing-tighter);
overflow-wrap: anywhere;
}
.contact-detail__subtitle {
color: var(--color-text-secondary);
}
/* 칸 — 왼쪽 이름 88 · 사이 12 16 */
.contact-detail__fields {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: var(--space-12) var(--space-16);
margin: 0;
}
.contact-detail__fields dt {
padding-top: var(--space-1);
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-muted);
}
.contact-detail__fields dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.contact-detail__list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.contact-detail__item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.contact-detail__address {
font-weight: 500;
text-decoration: none;
overflow-wrap: anywhere;
}
.contact-detail__address:hover {
text-decoration: underline;
}
.contact-detail__kind {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.contact-detail__number {
font-variant-numeric: tabular-nums;
}
.contact-detail__groups {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-12);
}
.contact-detail__group {
color: var(--color-brand);
font-weight: 500;
text-decoration: none;
}
.contact-detail__group:hover {
color: var(--color-brand-strong);
text-decoration: underline;
}
/* 메모 — 줄바꿈 그대로 */
.contact-detail__note {
white-space: pre-line;
color: var(--color-text-body);
}
/* 사내 상세 아래 안내 — 위 선 (디자인 원본 Contacts) */
.contact-detail__hint {
margin: 0;
padding-top: var(--space-14);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.contact-detail__loading {
display: flex;
flex-direction: column;
gap: var(--space-12);
max-width: var(--content-max-width);
padding: calc(var(--size-header-min) + var(--space-22)) var(--content-inline) var(--content-block-end);
}
.contact-detail__bar-skeleton {
width: 60%;
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.contact-detail__bar-skeleton--title {
width: 40%;
height: var(--space-18);
background: var(--color-skeleton);
}
.contact-detail__bar-skeleton--short {
width: 30%;
}
.contact-detail__leave {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.contact-detail__leave:hover {
background: var(--color-brand-subtle);
}
/* 넓은 화면에서는 상세 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.contact-detail {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,550 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Contact, ContactGroup, ContactInput } from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
import type { ContactPrefill } from '@/shared/utils/contacts'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ContactDialog from './ContactDialog.vue'
// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const GROUPS: ContactGroup[] = [
{ id: 'g1', name: '거래처', memberCount: 4 },
{ id: 'g2', name: '프로젝트 알파', memberCount: 4 },
]
const SEOYUN: Contact = {
id: 'c1',
name: '강서윤',
emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }],
phones: [],
company: '(주)한빛상사',
department: '구매팀',
title: '과장',
note: null,
groupIds: ['g1'],
}
const JIHOON: Contact = {
id: 'c2',
name: '박지훈',
emails: [
{ address: 'jihoon.park@hanbit.example', label: 'work' },
{ address: 'jihoon.p@mail.example', label: 'personal' },
],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '(주)한빛상사',
department: '영업팀',
title: '팀장',
note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.',
groupIds: ['g1', 'g2'],
}
function pageOf<T>(items: T[]): PaginatedResult<T> {
return { items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } }
}
interface Server {
/** GET /contacts?q= — 같은 주소 찾기 */
search?: (q: string) => Contact[]
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('ContactDialog', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function installServer(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url === '/contact-groups') return ok(pageOf(GROUPS))
if (request.url === '/contacts' && request.method === 'GET') {
return ok(pageOf(server.search?.(String(request.params.q ?? '')) ?? []))
}
if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201)
if (request.method === 'POST' || request.method === 'PATCH') {
return server.save ? server.save(request) : ok({ ...JIHOON, ...(request.data as ContactInput), id: 'c9' })
}
if (request.method === 'DELETE') return ok({ contact: JIHOON, restoreToken: 't-c2' })
return ok(null)
})
}
async function mountDialog(
props: { contact?: Contact | null; prefill?: ContactPrefill | null } = {},
server: Server = {},
path = '/contacts',
) {
installServer(server)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const closes: number[] = []
const wrapper: VueWrapper = mount(ContactDialog, {
attachTo: document.body,
props: {
open: true,
contact: props.contact ?? null,
prefill: props.prefill ?? null,
onClose: () => {
closes.push(closes.length + 1)
return wrapper.setProps({ open: false })
},
},
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, closes }
}
// 이메일 칸은 type="text" + inputmode="email" (type="email" 이면 Chrome 이 한글 도메인을 퓨니코드로 바꿀 수 있다)
const emailInputs = (wrapper: VueWrapper) => wrapper.findAll('input[inputmode="email"]')
const phoneInputs = (wrapper: VueWrapper) => wrapper.findAll('input[type="tel"]')
const saves = () =>
requests.filter(
(request) => (request.method === 'POST' && request.url === '/contacts') || request.method === 'PATCH',
)
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
const isOpen = (wrapper: VueWrapper, selector: string) => wrapper.find(`${selector}[open]`).exists()
it('새 연락처 — 이메일 · 전화번호 빈 줄 하나씩과 안내, 그룹은 받아 둔다 · 처음 초점은 이름', async () => {
const { wrapper } = await mountDialog()
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가')
expect(document.activeElement?.id).toBe('contact-name')
expect(emailInputs(wrapper).map((input) => input.attributes('aria-label'))).toEqual(['첫째 이메일'])
expect(wrapper.get('select[aria-label="첫째 이메일의 종류"]').findAll('option').map((option) => option.text())).toEqual([
'회사',
'개인',
'기타',
])
expect(wrapper.get('select[aria-label="첫째 전화번호의 종류"]').findAll('option').map((option) => option.text())).toEqual([
'휴대전화',
'회사',
'집',
'기타',
])
expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다')
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.')
expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000')
expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([
'g1',
'g2',
])
expect(wrapper.find('#contact-remove').exists()).toBe(false)
expect(requests.map((request) => request.url)).toEqual(['/contact-groups'])
})
it('잘못은 [저장]을 누를 때만 칸 아래 빨간 글 — 첫 잘못된 칸에 초점, 알림 없음 · 고치면 그 칸의 잘못을 내린다', async () => {
const { wrapper } = await mountDialog()
await wrapper.get('#contact-save').trigger('click')
expect(wrapper.get('#contact-required').classes()).toContain('form-message--error')
expect(document.activeElement?.id).toBe('contact-name')
await wrapper.get('#contact-name').setValue('박지훈')
await emailInputs(wrapper)[0]?.setValue('jihoon.park@hanbit')
await phoneInputs(wrapper)[0]?.setValue('내선 12')
await wrapper.get('#contact-save').trigger('click')
const email = emailInputs(wrapper)[0]
expect(email?.attributes('aria-invalid')).toBe('true')
expect(wrapper.get(`#${email?.attributes('aria-describedby')}`).text()).toBe('이메일 형식이 올바르지 않습니다.')
expect(document.activeElement).toBe(email?.element)
expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true')
expect(saves()).toEqual([])
expect(toasts()).toEqual([])
await email?.setValue('jihoon.park@hanbit.example')
expect(email?.attributes('aria-invalid')).toBe('false')
})
it('저장 — 앞뒤 공백 · 빈 줄을 빼서 보내고 "연락처를 저장했습니다." · 창을 닫는다', async () => {
const { wrapper, closes } = await mountDialog()
await wrapper.get('#contact-name').setValue(' 한도경 ')
await emailInputs(wrapper)[0]?.setValue(' dokyung.han@mail.example ')
await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal')
await wrapper.get('#contact-company').setValue(' ')
await wrapper.get('#contact-note').setValue('대학 동기\n')
expect(wrapper.get('[data-contact-note-count]').text()).toBe('6 / 1,000')
await wrapper.get('[data-contact-group="g2"]').setValue(true)
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves().map((request) => [request.method, request.url])).toEqual([['POST', '/contacts']])
expect(saves()[0]?.data).toEqual({
name: '한도경',
emails: [{ address: 'dokyung.han@mail.example', label: 'personal' }],
phones: [],
company: null,
department: null,
title: null,
note: '대학 동기',
groupIds: ['g2'],
})
expect(toasts()).toEqual(['연락처를 저장했습니다.'])
expect(closes).toHaveLength(1)
})
it('이메일 칸은 type="text" + inputmode="email" · [더하기]는 옆 안내("5개까지 …")를 까닭으로 잇는다(aria-describedby)', async () => {
const { wrapper } = await mountDialog()
const email = emailInputs(wrapper)[0]
expect(email?.attributes('type')).toBe('text')
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe(
'5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
)
})
it('고치기 — PATCH 로 바꾼 칸만 보낸다(이메일 · 전화번호 · 그룹은 바뀌면 통째로) · 바꾼 것이 없으면 보내지 않고 닫는다', async () => {
const { wrapper, closes } = await mountDialog({ contact: JIHOON })
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves()).toEqual([])
expect(closes).toHaveLength(1)
await wrapper.setProps({ open: true })
await flushPromises()
await wrapper.get('#contact-title').setValue('부장')
await phoneInputs(wrapper)[0]?.setValue('010-3192-0000')
await wrapper.get('[data-contact-group="g2"]').setValue(false)
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']])
expect(saves()[0]?.data).toEqual({
phones: [{ number: '010-3192-0000', label: 'mobile' }],
title: '부장',
groupIds: ['g1'],
})
expect(toasts()).toEqual(['연락처를 저장했습니다.'])
})
it('고치기는 바꾼 칸만 검사한다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 회사)은 그대로 저장된다', async () => {
const imported: Contact = {
...JIHOON,
emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
phones: [{ number: '내선 12', label: 'work' }],
company: '한'.repeat(150),
note: '가'.repeat(1200),
}
const { wrapper } = await mountDialog({ contact: imported })
await wrapper.get('#contact-title').setValue('부장')
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves()[0]?.data).toEqual({ title: '부장' })
})
it('고친 칸은 그 칸 전부가 규칙에 맞아야 한다 — 전화번호를 고치면 남의 "내선" 번호도, 회사를 고치면 100자, 이메일을 고치면 20개', async () => {
const imported: Contact = {
...JIHOON,
emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
phones: [
{ number: '내선 12', label: 'work' },
{ number: '010-1111-2222', label: 'mobile' },
],
company: '한'.repeat(150),
}
const { wrapper } = await mountDialog({ contact: imported })
await phoneInputs(wrapper)[1]?.setValue('010-1111-3333')
await wrapper.get('#contact-company').setValue('한'.repeat(149))
await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal')
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves()).toEqual([])
expect(wrapper.get('[data-entry-count-error]').text()).toBe('이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요.')
expect(document.activeElement).toBe(emailInputs(wrapper)[0]?.element)
expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#contact-company').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#contact-company-error').text()).toBe('100자까지 쓸 수 있습니다.')
})
it('고치기 — 다른 프로그램이 넣은 6개 이상의 주소는 그대로 보이고 저장된다 · [이메일 더하기]는 5개에서 멈춘다', async () => {
const many: Contact = {
...JIHOON,
emails: Array.from({ length: 6 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
}
const { wrapper } = await mountDialog({ contact: many })
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 고치기')
expect(emailInputs(wrapper)).toHaveLength(6)
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
expect(addEmail?.attributes('aria-disabled')).toBe('true')
await addEmail?.trigger('click')
expect(emailInputs(wrapper)).toHaveLength(6)
// 전화번호는 하나 — 더할 수 있다
const addPhone = wrapper.findAll('.entry-rows__add-button')[1]
expect(addPhone?.attributes('aria-disabled')).toBeUndefined()
await addPhone?.trigger('click')
await flushPromises()
expect(phoneInputs(wrapper)).toHaveLength(2)
expect(document.activeElement).toBe(phoneInputs(wrapper)[1]?.element)
await phoneInputs(wrapper)[1]?.setValue('02-555-0147')
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
// 이메일은 손대지 않았다 — 전화번호만 보낸다
expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']])
expect(saves()[0]?.data).toEqual({
phones: [
{ number: '010-3192-5540', label: 'mobile' },
{ number: '02-555-0147', label: 'mobile' },
],
})
})
it('같은 주소 — 이메일 칸을 떠나면 알림 없이 찾아 갈색 글 + [그 연락처 보기] (자기 자신은 뺀다) · 막지 않는다', async () => {
const { wrapper } = await mountDialog({}, { search: () => [SEOYUN] })
await useContactsStore().loadGroups()
const email = emailInputs(wrapper)[0]
await email?.setValue('Seoyun.Kang@hanbit.example')
await email?.trigger('blur')
await flushPromises()
const search = requests.find((request) => request.url === '/contacts')
expect(search?.params).toEqual({ q: 'Seoyun.Kang@hanbit.example', limit: 100 })
expect(search?.config.silent).toBe(true)
const warning = wrapper.get('[data-contact-duplicate]')
expect(warning.text()).toBe(
'이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기',
)
expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id'))
expect(email?.attributes('aria-invalid')).toBe('false')
// 고치기 창에서 자기 자신의 주소는 경고하지 않는다
const own = await mountDialog({ contact: SEOYUN }, { search: () => [SEOYUN] })
const ownEmail = emailInputs(own.wrapper)[0]
await ownEmail?.trigger('blur')
await flushPromises()
expect(own.wrapper.find('[data-contact-duplicate]').exists()).toBe(false)
// 고치는 동안은 옛 주소의 안내를 내린다
await email?.setValue('seoyun.k@hanbit.example')
expect(wrapper.find('[data-contact-duplicate]').exists()).toBe(false)
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(saves()).toHaveLength(1)
})
it('채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아보고, 채운 그대로면 닫을 때 묻지 않는다', async () => {
const { wrapper, closes } = await mountDialog(
{ prefill: { name: '강서윤', email: 'seoyun.kang@hanbit.example' } },
{ search: () => [SEOYUN] },
)
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤')
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤')
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
expect(closes).toHaveLength(1)
})
it('[그 연락처 보기] — 창을 닫고 그 연락처를 고른 주소록으로 (바꾼 것이 있으면 먼저 묻는다)', async () => {
const { wrapper, router, closes } = await mountDialog({}, { search: () => [SEOYUN] }, '/mail/inbox')
await wrapper.get('#contact-name').setValue('강 과장')
const email = emailInputs(wrapper)[0]
await email?.setValue('seoyun.kang@hanbit.example')
await email?.trigger('blur')
await flushPromises()
await wrapper.get('.entry-rows__link').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
expect(wrapper.get('#contact-leave-title').text()).toBe('저장하지 않은 연락처가 있습니다')
expect(wrapper.get('#contact-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.')
await wrapper.get('#contact-leave-confirm').trigger('click')
await flushPromises()
expect(closes).toHaveLength(1)
expect(router.currentRoute.value.name).toBe('contacts-contact')
expect(router.currentRoute.value.params).toEqual({ contactId: 'c1' })
})
it('다른 프로그램이 넣은 제어 문자(탭 · CR LF)는 열 때 고친다 — 고친 것만으로는 [취소]에 묻지 않고, 저장하면 고친 값을 보낸다', async () => {
const imported: Contact = { ...JIHOON, name: '박지훈\t', title: '팀장\u0000', note: '첫 줄\r\n둘째\t줄' }
const { wrapper, closes } = await mountDialog({ contact: imported })
expect((wrapper.get('#contact-note').element as HTMLTextAreaElement).value).toBe('첫 줄\n둘째 줄')
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
expect(closes).toHaveLength(1)
await wrapper.setProps({ open: true })
await flushPromises()
await wrapper.get('#contact-department').setValue('영업\t2팀')
await wrapper.get('#contact-note').setValue('첫 줄\n셋째\t줄')
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
// 고친 칸만 고친 값으로 — 손대지 않은 이름 · 직책(남의 제어 문자)은 보내지 않는다
expect(saves()[0]?.data).toEqual({ department: '영업 2팀', note: '첫 줄\n셋째 줄' })
})
it('다른 프로그램이 넣은 1,000자 넘는 메모를 고쳤으면 [저장] 때 메모 아래에 알린다 — 보내지 않는다', async () => {
const { wrapper } = await mountDialog({ contact: { ...JIHOON, note: '가'.repeat(1200) } })
expect(wrapper.get('[data-contact-note-count]').text()).toBe('1,200 / 1,000')
await wrapper.get('#contact-note').setValue('가'.repeat(1100))
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(wrapper.get('#contact-note').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#contact-note-error').text()).toBe('메모는 1,000자까지 쓸 수 있습니다.')
expect(document.activeElement?.id).toBe('contact-note')
expect(saves()).toEqual([])
})
it('나가기 확인 — 바꾼 것이 있으면 [취소] · Esc 에 묻는다: [계속 고치기]면 그대로, [닫기]면 닫는다 · 다른 화면으로 가면 [나가기]', async () => {
const { wrapper, router, closes } = await mountDialog({ contact: JIHOON }, {}, '/contacts/all/c2')
await wrapper.get('#contact-title').setValue('부장')
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
expect(document.activeElement?.id).toBe('contact-leave-stay')
expect(wrapper.get('#contact-leave-stay').text()).toBe('계속 고치기')
expect(wrapper.get('#contact-leave-confirm').text()).toBe('닫기')
await wrapper.get('#contact-leave-stay').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
expect(isOpen(wrapper, '#contact-dialog')).toBe(true)
expect(closes).toEqual([])
const leaving = router.push('/mail/inbox')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
expect(wrapper.get('#contact-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.')
expect(wrapper.get('#contact-leave-confirm').text()).toBe('나가기')
await wrapper.get('#contact-leave-confirm').trigger('click')
await leaving
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('서버의 형식 거절(VAL_001)은 단추 줄 위에 사전 문구 — 알림 없이 · 한도(CONTACT_002)는 사전 문구 알림, 창은 그대로', async () => {
let reply: MockResult = fail(400, 'VAL_001')
const { wrapper, closes } = await mountDialog(
{ prefill: { name: null, email: 'dokyung.han@mail.example' } },
{ save: () => reply },
)
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(wrapper.get('#contact-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(toasts()).toEqual([])
reply = fail(409, 'CONTACT_002')
await wrapper.get('#contact-name').setValue('한도경')
expect(wrapper.find('#contact-error').exists()).toBe(false)
await wrapper.get('#contact-save').trigger('click')
await flushPromises()
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.CONTACT_002])
expect(wrapper.find('#contact-error').exists()).toBe(false)
expect(closes).toEqual([])
})
it('[이 연락처 지우기] — 확인 없이 지우고 닫는다 · 알림의 [되돌리기]가 지운 연락처를 다시 만든다', async () => {
const { wrapper, closes } = await mountDialog({ contact: JIHOON })
await useContactsStore().loadGroups()
await wrapper.get('#contact-remove').trigger('click')
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/contacts/c2')).toBe(true)
expect(closes).toHaveLength(1)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘박지훈’ 연락처를 지웠습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
// 되돌리기는 지우기 응답의 표로 — 서버가 지운 그대로(다른 프로그램의 칸까지) 다시 만든다
expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({
method: 'POST',
data: { token: 't-c2' },
})
expect(saves()).toEqual([])
expect(toasts()).toContain('연락처를 되돌렸습니다.')
})
it('저장을 보내는 사이 창을 닫았으면(취소) 창 안에 보일 거절(VAL_001)을 알림으로 알린다', async () => {
let finish: (result: MockResult) => void = () => undefined
const { wrapper, closes } = await mountDialog(
{ prefill: { name: '한도경', email: 'dokyung.han@mail.example' } },
{
save: () =>
new Promise<MockResult>((resolve) => {
finish = resolve
}),
},
)
await wrapper.get('#contact-save').trigger('click')
// 보내는 중에는 묻지 않고 닫는다 (결과는 알림으로)
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(closes).toHaveLength(1)
finish(fail(400, 'VAL_001'))
await flushPromises()
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect(wrapper.find('#contact-error').exists()).toBe(false)
})
it('보내는 사이 세션이 바뀌면(로그아웃 · 다른 계정) 늦게 온 응답으로 알리지도 닫지도 않는다', async () => {
let finish: (result: MockResult) => void = () => undefined
const late = new Promise<MockResult>((resolve) => {
finish = resolve
})
const { wrapper, closes } = await mountDialog(
{ prefill: { name: '한도경', email: 'dokyung.han@mail.example' } },
{ save: () => late },
)
await wrapper.get('#contact-save').trigger('click')
expect(wrapper.get('#contact-save').attributes('aria-disabled')).toBe('true')
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
finish(ok({ ...JIHOON, id: 'c9' }))
await flushPromises()
expect(toasts()).toEqual([])
expect(closes).toEqual([])
})
})
@@ -0,0 +1,761 @@
<script setup lang="ts">
// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit "연락처 추가" · README "8단계 › 연락처 추가 · 고치기" — 모달 620, 필터 고치기 창과 같은 틀)
// 이름(100자) → 이메일(종류 · 주소 · ✕, [이메일 더하기] 5개까지) → 전화번호(종류 · 번호 · ✕, 5개까지) → 회사 · 부서 · 직책(한 줄에 셋) →
// 메모(1,000자 — "38 / 1,000") → 그룹(체크 칸) → "이름이나 이메일 가운데 하나는 있어야 합니다." → [이 연락처 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 고치기는 상세의 [고치기]가 그 연락처를 새로 받아 연다(useContactActions 의 loadForEdit). 저장은 PATCH 로 연 때와 달라진 칸만 보낸다
// (contacts.ts 의 contactPatchOf — 창을 연 사이 다른 곳에서 바꾼 다른 칸을 덮지 않게). 바꾼 것이 없으면 보내지 않고 닫는다
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 첫 잘못된 칸에 초점, 알림 없음. 규칙은 서버와 같거나 더 좁다(이메일은 서버보다 좁게 —
// contacts.ts 의 contactDraftProblems). 고치기는 바꾼 칸만 본다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모)은 그대로 둔다.
// 칸을 고치면 그 칸의 잘못을 내린다. 서버의 형식 거절(VAL_001 — 다른 프로그램이 uid 없이 만든 카드를 그룹에 넣을 때도)은 단추 줄 위에 사전 문구로,
// 한도(CONTACT_002) · 장애는 인터셉터의 알림으로. 보내는 사이 창을 닫았으면 창 안에 보일 거절도 알림으로 알린다
// - 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처 — 대소문자 무시)가 있으면 그 칸 아래 갈색 글 + [그 연락처 보기] — 막지 않는다
// (useContactDuplicateCheck). 채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아본다
// - 다른 프로그램이 넣은 6개 이상의 주소 · 전화번호는 그대로 보이고 저장된다 — [더하기]만 5개에서 멈춘다
// - 지우기는 확인 없이 — 창을 닫고 알림의 [되돌리기] (useContactActions)
// - 나가기 확인(useDialogLeaveConfirm — 필터 고치기 창과 같다): 창을 연 때와 다르면 [취소] · Esc 는 "저장하지 않은 연락처가 있습니다"
// ([계속 고치기] — 처음 초점 · Esc, [닫기]), 창을 연 채 다른 화면으로 가면 같은 창에 [나가기], 탭 닫기 · 새로고침은 브라우저 확인창.
// 채워 연 새 연락처는 채운 그대로면 묻지 않는다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로)
// - [그 연락처 보기]는 이 창을 닫는(바꾼 것이 있으면 먼저 묻는) 길로 그 연락처를 고른 주소록에 간다
// 고정 id — #contact-dialog · #contact-dialog-title · #contact-name · #contact-email-{n} · #contact-phone-{n} · #contact-company ·
// #contact-department · #contact-title · #contact-note · #contact-required · #contact-error · #contact-remove · #contact-save · #contact-cancel ·
// 나가기 확인 #contact-leave-dialog · #contact-leave-stay · #contact-leave-confirm
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ContactEntryRows from '@/components/contacts/ContactEntryRows.vue'
import type { ContactEntryPatch } from '@/components/contacts/contacts-ui'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { CONTACT_SAVE_INLINE_CODES, useContactActions } from '@/composables/useContactActions'
import { notifyApiError, toApiError } from '@/composables/useApi'
import { useContactDuplicateCheck } from '@/composables/useContactDuplicateCheck'
import { useDialogLeaveConfirm } from '@/composables/useDialogLeaveConfirm'
import {
CONTACT_DIALOG_TEXT,
CONTACT_EMAIL_LABEL_TEXT,
CONTACT_EMAIL_LABELS,
CONTACT_EMAIL_MAX_LENGTH,
CONTACT_EMAILS_MAX,
CONTACT_FIELD_ROWS_MAX,
CONTACT_LEAVE_DIALOG,
CONTACT_NOTE_MAX_LENGTH,
CONTACT_PHONE_LABEL_TEXT,
CONTACT_PHONE_LABELS,
CONTACT_PHONE_MAX_LENGTH,
CONTACT_PHONES_MAX,
CONTACT_TEXT_MAX_LENGTH,
} from '@/shared/constants/contacts'
import type { Contact, ContactEmailLabel, ContactPhoneLabel } from '@/shared/types/contacts'
import {
contactDraftOf,
contactDraftProblems,
contactInputOf,
contactPatchOf,
copyContactDraft,
emptyEmailRow,
emptyPhoneRow,
hasContactProblems,
noContactProblems,
sameContactDraft,
type ContactDraft,
type ContactDraftProblems,
type ContactPrefill,
type ContactTextField,
} from '@/shared/utils/contacts'
import { contactLocation } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
/** 고칠 연락처 — null 이면 새 연락처 */
contact: Contact | null
/** 새 연락처를 채울 값 (읽기 화면의 보낸 사람 · 사내 목록의 직원) */
prefill?: ContactPrefill | null
}
interface Emits {
/** 닫았다 — 저장 · 지우기를 마쳤거나 [취소] · Esc (바꾼 것이 있었으면 확인한 뒤) */
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
prefill: null,
})
const emit = defineEmits<Emits>()
const contactsStore = useContactsStore()
const actions = useContactActions()
const router = useRouter()
const modalRef = ref<InstanceType<typeof ModalDialog> | null>(null)
const nameRef = ref<HTMLInputElement | null>(null)
const noteRef = ref<HTMLTextAreaElement | null>(null)
const emailRowsRef = ref<InstanceType<typeof ContactEntryRows> | null>(null)
const phoneRowsRef = ref<InstanceType<typeof ContactEntryRows> | null>(null)
const draft = reactive<ContactDraft>(contactDraftOf(props.contact, props.prefill))
const problems = ref<ContactDraftProblems>(noContactProblems(draft))
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인 · 고치기의 바꾼 칸) */
const opened = shallowRef<ContactDraft>(copyContactDraft(draft))
/** 같은 주소 안내 */
const duplicates = useContactDuplicateCheck({
rows: () => draft.emails,
exceptId: () => props.contact?.id ?? null,
})
const pending = ref<'save' | 'remove' | null>(null)
/** 서버의 형식 거절(VAL_001) — 단추 줄 위 */
const error = ref<string | null>(null)
/** 닫는 중 — 닫기로 했으니 나가기 확인을 다시 걸지 않는다 */
const closing = ref<boolean>(false)
/** 연 차례 — 늦게 온 결과가 그사이 다시 연 창을 건드리지 않게 */
let openToken = 0
let unmounted = false
/** 닫은 뒤 할 일 — [그 연락처 보기] */
let afterClose: (() => void) | null = null
const title = computed(() => (props.contact ? CONTACT_DIALOG_TEXT.editTitle : CONTACT_DIALOG_TEXT.createTitle))
const busy = computed(() => pending.value !== null)
const changed = computed(() => props.open && !closing.value && !sameContactDraft(draft, opened.value))
const groups = computed(() => contactsStore.groups)
const noteCount = computed(() =>
CONTACT_DIALOG_TEXT.noteCount(formatCount(draft.note.length), formatCount(CONTACT_NOTE_MAX_LENGTH)),
)
const emailKinds = CONTACT_EMAIL_LABELS.map((value) => ({ value, text: CONTACT_EMAIL_LABEL_TEXT[value] }))
const phoneKinds = CONTACT_PHONE_LABELS.map((value) => ({ value, text: CONTACT_PHONE_LABEL_TEXT[value] }))
const emailRows = computed(() => draft.emails.map((row) => ({ key: row.key, label: row.label, value: row.address })))
const phoneRows = computed(() => draft.phones.map((row) => ({ key: row.key, label: row.label, value: row.number })))
const emailErrors = computed(() =>
problems.value.emails.map((problem) => {
if (problem === 'invalid') return CONTACT_DIALOG_TEXT.emailInvalid
if (problem === 'repeated') return CONTACT_DIALOG_TEXT.emailRepeated
return null
}),
)
const emailWarnings = duplicates.warnings
const phoneErrors = computed(() =>
problems.value.phones.map((problem) => (problem ? CONTACT_DIALOG_TEXT.phoneInvalid : null)),
)
const noteError = computed(() =>
problems.value.note ? CONTACT_DIALOG_TEXT.noteTooLong(formatCount(CONTACT_NOTE_MAX_LENGTH)) : null,
)
const textLimitError = CONTACT_DIALOG_TEXT.textTooLong(formatCount(CONTACT_TEXT_MAX_LENGTH))
const emailCountError = computed(() =>
problems.value.emailCount ? CONTACT_DIALOG_TEXT.emailsTooMany(formatCount(CONTACT_EMAILS_MAX)) : null,
)
const phoneCountError = computed(() =>
problems.value.phoneCount ? CONTACT_DIALOG_TEXT.phonesTooMany(formatCount(CONTACT_PHONES_MAX)) : null,
)
/** 회사 · 부서 · 직책 — 한 줄에 셋 (같은 모양이라 차례대로 그린다) */
const ORG_FIELDS: readonly { field: Exclude<ContactTextField, 'name'>; label: string }[] = [
{ field: 'company', label: CONTACT_DIALOG_TEXT.company },
{ field: 'department', label: CONTACT_DIALOG_TEXT.department },
{ field: 'title', label: CONTACT_DIALOG_TEXT.title },
]
/** 열 때마다 — 그 연락처(또는 채운 새 연락처)로 칸을 다시 채운다. 그룹을 아직 받지 않았으면 받는다 */
function resetDraft() {
openToken += 1
Object.assign(draft, contactDraftOf(props.contact, props.prefill))
problems.value = noContactProblems(draft)
opened.value = copyContactDraft(draft)
duplicates.reset()
pending.value = null
error.value = null
closing.value = false
afterClose = null
contactsStore.ensureGroups()
// 채운 주소가 이미 주소록에 있으면 바로 보인다 (읽기 화면 ⋯ · 사내 목록의 [내 주소록에 추가])
const filled = draft.emails[0]
if (!props.contact && props.prefill && filled) void duplicates.check(filled.key)
}
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
function edited(patch: Partial<ContactDraftProblems>) {
problems.value = { ...problems.value, ...patch }
error.value = null
}
function setText(field: ContactTextField, event: Event) {
draft[field] = (event.target as HTMLInputElement).value
const texts = { ...problems.value.texts, [field]: false }
edited(field === 'name' ? { required: false, texts } : { texts })
}
function setNote(event: Event) {
draft.note = (event.target as HTMLTextAreaElement).value
edited({ note: false })
}
function updateEmail(index: number, patch: ContactEntryPatch) {
const row = draft.emails[index]
if (!row) return
if (patch.label !== undefined) row.label = patch.label as ContactEmailLabel
if (patch.value !== undefined) {
row.address = patch.value
// 고치는 동안의 같은 주소 안내는 옛 주소의 것이다 — 칸을 떠나면 다시 찾아본다
duplicates.forget(row.key)
}
edited({
required: false,
emailCount: false,
emails: problems.value.emails.map((problem, at) => (at === index ? null : problem)),
})
}
function updatePhone(index: number, patch: ContactEntryPatch) {
const row = draft.phones[index]
if (!row) return
if (patch.label !== undefined) row.label = patch.label as ContactPhoneLabel
if (patch.value !== undefined) row.number = patch.value
edited({ phoneCount: false, phones: problems.value.phones.map((problem, at) => (at === index ? null : problem)) })
}
function addEmail() {
if (draft.emails.length >= CONTACT_FIELD_ROWS_MAX) return
draft.emails.push(emptyEmailRow())
edited({ emails: [...problems.value.emails, null] })
void nextTick(() => emailRowsRef.value?.focusValue(draft.emails.length - 1))
}
function addPhone() {
if (draft.phones.length >= CONTACT_FIELD_ROWS_MAX) return
draft.phones.push(emptyPhoneRow())
edited({ phones: [...problems.value.phones, null] })
void nextTick(() => phoneRowsRef.value?.focusValue(draft.phones.length - 1))
}
/** 줄을 뺀 뒤의 초점 — 같은 자리의 줄(마지막이었으면 앞 줄), 없으면 [더하기] */
function focusAfterRemove(rows: InstanceType<typeof ContactEntryRows> | null, index: number, count: number) {
void nextTick(() => {
if (count === 0) rows?.focusAdd()
else rows?.focusValue(Math.min(index, count - 1))
})
}
function removeEmail(index: number) {
const [removed] = draft.emails.splice(index, 1)
if (removed) duplicates.forget(removed.key)
edited({ emailCount: false, emails: problems.value.emails.filter((_, at) => at !== index) })
focusAfterRemove(emailRowsRef.value, index, draft.emails.length)
}
function removePhone(index: number) {
draft.phones.splice(index, 1)
edited({ phoneCount: false, phones: problems.value.phones.filter((_, at) => at !== index) })
focusAfterRemove(phoneRowsRef.value, index, draft.phones.length)
}
function toggleGroup(groupId: string, checked: boolean) {
const others = draft.groupIds.filter((id) => id !== groupId)
draft.groupIds = checked ? [...others, groupId] : others
edited({})
}
/** 이메일 칸을 떠났다 — 내 주소록의 다른 연락처에 같은 주소가 있는지 알림 없이 찾아본다 (막지 않는다) */
function onEmailLeave(index: number) {
const row = draft.emails[index]
if (row) void duplicates.check(row.key)
}
function focusById(id: string) {
document.getElementById(id)?.focus()
}
/** 첫 잘못된 칸 — 이름(없음 · 너무 김) → 이메일(줄 · 개수) → 전화번호(줄 · 개수) → 회사 · 부서 · 직책 → 메모 */
function focusFirstProblem(found: ContactDraftProblems) {
const email = found.emails.findIndex((problem) => problem !== null)
const phone = found.phones.findIndex((problem) => problem !== null)
const org = ORG_FIELDS.find(({ field }) => found.texts[field])
if (found.required || found.texts.name) nameRef.value?.focus()
else if (email >= 0) emailRowsRef.value?.focusValue(email)
else if (found.emailCount) emailRowsRef.value?.focusValue(0)
else if (phone >= 0) phoneRowsRef.value?.focusValue(phone)
else if (found.phoneCount) phoneRowsRef.value?.focusValue(0)
else if (org) focusById(`contact-${org.field}`)
else if (found.note) noteRef.value?.focus()
}
/** 보낸 창이 아직 열려 있다 — 결과(칸 안 거절)를 창 안에 보일 수 있다 */
function isCurrent(token: number): boolean {
return token === openToken && !unmounted && props.open && !closing.value
}
async function save() {
if (busy.value) return
const contact = props.contact
const found = contactDraftProblems(draft, contact ? opened.value : null)
problems.value = found
if (hasContactProblems(found)) {
focusFirstProblem(found)
return
}
const patch = contact ? contactPatchOf(draft, opened.value) : null
// 고치기에서 바꾼 것이 없다 — 보낼 것이 없으니 닫는다 (빈 PATCH 는 서버가 받지 않는다)
if (patch && Object.keys(patch).length === 0) {
close()
return
}
pending.value = 'save'
error.value = null
const token = openToken
try {
const saved =
contact && patch ? await actions.updateContact(contact, patch) : await actions.createContact(contactInputOf(draft))
if (!isCurrent(token)) return
pending.value = null
if (saved) close()
} catch (caught) {
const apiError = toApiError(caught)
if (!isCurrent(token)) {
// 칸 안에 보일 곳이 사라졌다(창을 닫았거나 다시 열었다) — 끈 알림을 띄운다
if (CONTACT_SAVE_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
if (token === openToken && !unmounted) pending.value = null
return
}
pending.value = null
if (CONTACT_SAVE_INLINE_CODES.includes(apiError.code)) error.value = apiError.message
// 없는 그룹(그사이 다른 곳에서 지움) · 없는 연락처 — 그룹 칸을 다시 맞춘다 (알림은 인터셉터)
if (apiError.code === 'RES_001') void contactsStore.loadGroups()
}
}
async function remove() {
const contact = props.contact
if (busy.value || !contact) return
pending.value = 'remove'
const token = openToken
const removed = await actions.removeContact(contact)
if (token !== openToken || unmounted) return
pending.value = null
if (removed) close()
}
function close() {
closing.value = true
emit('close')
const next = afterClose
afterClose = null
next?.()
}
const { asking: leaveAsking, requestClose, stay: stayEditing, leave: leaveEditing } = useDialogLeaveConfirm({
changed: () => changed.value,
sending: () => busy.value,
close,
// 확인 창이 닫힌 뒤 — 브라우저가 이 창을 스스로 닫았으면(Esc 를 거듭 누름) 다시 띄운다. [그 연락처 보기]는 하지 않는다
stayed: () => {
afterClose = null
void nextTick(() => modalRef.value?.reshow())
},
})
const leaveCopy = computed(() =>
leaveAsking.value === 'route' ? CONTACT_LEAVE_DIALOG.route : CONTACT_LEAVE_DIALOG.close,
)
/** [그 연락처 보기] — 이 창을 닫고(바꾼 것이 있으면 먼저 묻는다) 그 연락처를 고른 주소록으로 */
function viewDuplicate(contactId: string) {
if (!contactId) return
afterClose = () => void router.push(contactLocation(contactId))
requestClose()
}
// 열 때마다 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
watch(
() => [props.open, props.contact, props.prefill] as const,
([open]) => {
if (open) resetDraft()
},
{ immediate: true },
)
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<ModalDialog
id="contact-dialog"
ref="modalRef"
:open="open"
:title="title"
title-id="contact-dialog-title"
wide
@cancel="requestClose"
>
<div class="contact-dialog">
<div class="contact-dialog__field">
<label
for="contact-name"
class="contact-dialog__label"
>{{ CONTACT_DIALOG_TEXT.name }}</label>
<input
id="contact-name"
ref="nameRef"
class="form-input"
type="text"
:value="draft.name"
:maxlength="CONTACT_TEXT_MAX_LENGTH"
autocomplete="off"
:aria-invalid="problems.required || problems.texts.name ? 'true' : 'false'"
:aria-describedby="problems.texts.name ? 'contact-name-error contact-required' : 'contact-required'"
autofocus
@input="setText('name', $event)"
>
<p
v-if="problems.texts.name"
id="contact-name-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ textLimitError }}</span>
</p>
</div>
<ContactEntryRows
ref="emailRowsRef"
id-prefix="contact-email"
:legend="CONTACT_DIALOG_TEXT.email"
:rows="emailRows"
:kinds="emailKinds"
input-type="email"
:max-length="CONTACT_EMAIL_MAX_LENGTH"
:kind-label="CONTACT_DIALOG_TEXT.emailKind"
:value-label="CONTACT_DIALOG_TEXT.emailAddress"
:remove-label="CONTACT_DIALOG_TEXT.emailRemove"
:add-label="CONTACT_DIALOG_TEXT.addEmail"
:hint="CONTACT_DIALOG_TEXT.emailHint"
:errors="emailErrors"
:warnings="emailWarnings"
:count-error="emailCountError"
:can-add="draft.emails.length < CONTACT_FIELD_ROWS_MAX"
@update="updateEmail"
@add="addEmail"
@remove="removeEmail"
@leave="onEmailLeave"
@view="viewDuplicate"
/>
<ContactEntryRows
ref="phoneRowsRef"
id-prefix="contact-phone"
:legend="CONTACT_DIALOG_TEXT.phone"
:rows="phoneRows"
:kinds="phoneKinds"
input-type="tel"
:max-length="CONTACT_PHONE_MAX_LENGTH"
:kind-label="CONTACT_DIALOG_TEXT.phoneKind"
:value-label="CONTACT_DIALOG_TEXT.phoneNumber"
:remove-label="CONTACT_DIALOG_TEXT.phoneRemove"
:add-label="CONTACT_DIALOG_TEXT.addPhone"
:hint="CONTACT_DIALOG_TEXT.phoneHint"
:errors="phoneErrors"
:count-error="phoneCountError"
:can-add="draft.phones.length < CONTACT_FIELD_ROWS_MAX"
@update="updatePhone"
@add="addPhone"
@remove="removePhone"
/>
<div class="contact-dialog__org">
<div
v-for="{ field, label } in ORG_FIELDS"
:key="field"
class="contact-dialog__field"
>
<label
:for="`contact-${field}`"
class="contact-dialog__label"
>{{ label }}</label>
<input
:id="`contact-${field}`"
class="form-input contact-dialog__compact"
type="text"
:value="draft[field]"
:maxlength="CONTACT_TEXT_MAX_LENGTH"
autocomplete="off"
:aria-invalid="problems.texts[field] ? 'true' : 'false'"
:aria-describedby="problems.texts[field] ? `contact-${field}-error` : undefined"
@input="setText(field, $event)"
>
<p
v-if="problems.texts[field]"
:id="`contact-${field}-error`"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ textLimitError }}</span>
</p>
</div>
</div>
<div class="contact-dialog__field">
<div class="contact-dialog__label-row">
<label
for="contact-note"
class="contact-dialog__label"
>{{ CONTACT_DIALOG_TEXT.note }}</label>
<span
class="contact-dialog__count"
data-contact-note-count
>{{ noteCount }}</span>
</div>
<textarea
id="contact-note"
ref="noteRef"
class="form-input contact-dialog__note"
rows="2"
:value="draft.note"
:maxlength="CONTACT_NOTE_MAX_LENGTH"
:aria-invalid="noteError ? 'true' : 'false'"
:aria-describedby="noteError ? 'contact-note-error' : undefined"
@input="setNote"
/>
<p
v-if="noteError"
id="contact-note-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ noteError }}</span>
</p>
</div>
<fieldset
v-if="groups.length > 0"
class="contact-dialog__groups"
>
<legend class="contact-dialog__label contact-dialog__legend">
{{ CONTACT_DIALOG_TEXT.groups }}
</legend>
<div class="contact-dialog__group-list">
<label
v-for="group in groups"
:key="group.id"
class="contact-dialog__group"
>
<input
type="checkbox"
class="contact-dialog__check"
:checked="draft.groupIds.includes(group.id)"
:data-contact-group="group.id"
@change="toggleGroup(group.id, ($event.target as HTMLInputElement).checked)"
>
<span>{{ group.name }}</span>
</label>
</div>
</fieldset>
<p
id="contact-required"
:class="['form-message', 'contact-dialog__required', { 'form-message--error': problems.required }]"
>
<AppIcon
v-if="problems.required"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ CONTACT_DIALOG_TEXT.required }}</span>
</p>
<p
v-if="error"
id="contact-error"
class="form-message form-message--error"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</div>
<template #actions>
<button
v-if="contact"
id="contact-remove"
type="button"
class="modal-dialog__action contact-dialog__remove"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="pending === 'remove' ? 'true' : undefined"
@click="remove"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ CONTACT_DIALOG_TEXT.remove }}</span>
</button>
<button
id="contact-save"
type="button"
class="modal-dialog__action contact-dialog__save"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="pending === 'save' ? 'true' : undefined"
@click="save"
>
{{ CONTACT_DIALOG_TEXT.save }}
</button>
<button
id="contact-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="requestClose"
>
{{ CONTACT_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
<!-- 저장하지 않고 닫거나 떠나려 할 때 — 이 창 위에 뜬다 (닫기는 답을 받아 창을 닫고, 떠나기는 이동이 답을 기다린다) -->
<LeaveConfirmDialog
:open="leaveAsking !== null"
id-prefix="contact-leave"
:copy="leaveCopy"
@stay="stayEditing"
@leave="leaveEditing"
/>
</template>
<style scoped>
/* 묶음 사이 16 (디자인 원본 ContactsEdit — 필터 고치기 창과 같다) */
.contact-dialog {
display: flex;
flex-direction: column;
gap: var(--space-16);
margin: var(--space-8) 0 var(--space-6);
}
.contact-dialog__field {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.contact-dialog__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.contact-dialog__label-row {
display: flex;
justify-content: space-between;
gap: var(--space-8);
}
.contact-dialog__count {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 회사 · 부서 · 직책 — 한 줄에 셋, 사이 8 */
.contact-dialog__org {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-8);
}
/* 칸 높이 40 — 밀도 예외 (이메일 · 전화번호 줄과 같다, tokens.css --size-filter-control) */
.contact-dialog__compact {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.contact-dialog__note {
min-height: 4.5rem;
padding: var(--space-8) var(--space-10);
resize: vertical;
line-height: var(--line-height-base);
}
.contact-dialog__groups {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.contact-dialog__legend {
margin: 0 0 var(--space-4);
padding: 0;
}
.contact-dialog__group-list {
display: flex;
flex-wrap: wrap;
gap: 0 var(--space-20);
}
/* 체크 줄 44 — 줄 전체가 눌린다 (필터 창의 할 일 칸과 같다) */
.contact-dialog__group {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.contact-dialog__check {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.contact-dialog__required {
margin-top: calc(var(--space-4) * -1);
}
/* [이 연락처 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 ContactsEdit · 필터 창과 같다) */
.modal-dialog__action.contact-dialog__remove {
gap: var(--space-6);
margin-right: auto;
padding: 0 var(--space-12);
background: transparent;
color: var(--color-danger);
}
.modal-dialog__action.contact-dialog__remove:not([aria-disabled='true']):hover {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
.modal-dialog__action.contact-dialog__save {
padding: 0 var(--space-18);
}
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.modal-dialog__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,354 @@
<script setup lang="ts">
// 연락처 창의 이메일 · 전화번호 줄 묶음 (디자인 원본 ContactsEdit "연락처 추가") — 줄마다 종류 112 · 값 · ✕ 36×40, 아래에 [더하기] + 안내
// - 줄 높이 40 · ✕ 36×40 은 필터 고치기 창의 조건 줄과 같은 밀도 예외(--size-filter-control · --size-filter-remove)
// - 줄의 잘못(빨간 글)은 그 줄 아래, 같은 주소 안내(갈색 글 + [그 연락처 보기])도 그 줄 아래 — 값 칸이 aria-describedby 로 가리킨다.
// 같은 주소 안내는 칸을 떠난 뒤 늦게 뜨므로 줄마다 늘 있는 알림 영역(role=status) 안에 그린다 — 화면 읽기 프로그램이 읽는다
// - [더하기]는 5개까지(그보다 많은 줄 — 다른 프로그램이 넣은 것 — 은 그대로 보인다). 다 찼으면 aria-disabled (누른 단추가 초점을 잃지 않게) —
// 까닭(옆의 안내 "5개까지 …")을 aria-describedby 로 잇는다
// - 묶음 전체의 잘못(서버 한도 20개를 넘음 — 다른 프로그램이 넣은 것을 고쳤을 때)은 [더하기] 아래 빨간 글 (countError)
// - 이메일 칸은 type="text" + inputmode="email" — type="email" 이면 Chrome 이 한글 도메인(IDN)을 퓨니코드로 바꿀 수 있다(받는 사람 칸과 같다)
// - 줄을 빼면 초점은 다음 줄의 값 칸(마지막이었으면 앞 줄, 없으면 [더하기])으로 — 부모가 focusValue · focusAdd 로 옮긴다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { ContactEntryPatch, ContactEntryRowView, ContactEntryWarning } from '@/components/contacts/contacts-ui'
import { koreanOrdinal } from '@/shared/utils/contacts'
interface Props {
/** 고정 id 의 앞부분 — 값 칸 #{idPrefix}-{key}, 줄 아래 글 #{idPrefix}-{key}-message */
idPrefix: string
legend: string
rows: readonly ContactEntryRowView[]
kinds: readonly { value: string; text: string }[]
inputType: 'email' | 'tel'
maxLength: number
kindLabel: (ordinal: string) => string
valueLabel: (ordinal: string) => string
removeLabel: (ordinal: string) => string
addLabel: string
hint: string
/** 줄마다 잘못 — 없으면 null */
errors: readonly (string | null)[]
/** 줄마다 안내 — 없으면 null */
warnings?: readonly (ContactEntryWarning | null)[]
/** 묶음 전체의 잘못 — 없으면 null (서버 한도를 넘는 개수) */
countError?: string | null
/** [더하기]를 누를 수 있다 (5개 미만) */
canAdd: boolean
}
interface Emits {
(e: 'update', index: number, patch: ContactEntryPatch): void
(e: 'add'): void
(e: 'remove', index: number): void
/** 값 칸을 떠났다 — 같은 주소를 찾아본다 */
(e: 'leave', index: number): void
(e: 'view', contactId: string): void
}
const props = withDefaults(defineProps<Props>(), {
warnings: () => [],
countError: null,
})
const emit = defineEmits<Emits>()
const rootRef = ref<HTMLFieldSetElement | null>(null)
const addRef = ref<HTMLButtonElement | null>(null)
function valueId(key: number): string {
return `${props.idPrefix}-${key}`
}
function messageId(key: number): string {
return `${props.idPrefix}-${key}-message`
}
const hintId = `${props.idPrefix}-hint`
const countErrorId = `${props.idPrefix}-count-error`
function hasMessage(index: number): boolean {
return Boolean(props.errors[index] ?? props.warnings[index])
}
/** 값 칸이 가리키는 글 — 그 줄의 잘못 · 안내, 묶음 전체의 잘못 */
function describedBy(index: number, key: number): string | undefined {
const ids = [hasMessage(index) ? messageId(key) : '', props.countError ? countErrorId : '']
return ids.filter((id) => id !== '').join(' ') || undefined
}
function onAdd() {
if (props.canAdd) emit('add')
}
/** index 번째 줄의 값 칸에 초점 — v-for 의 ref 배열은 차례를 지키지 않아 id 로 찾는다 */
function focusValue(index: number) {
const row = props.rows[index]
if (!row) return
const input = rootRef.value?.querySelector<HTMLInputElement>(`[id="${valueId(row.key)}"]`)
input?.focus()
}
defineExpose({
focusValue,
focusAdd: () => addRef.value?.focus(),
})
</script>
<template>
<fieldset
ref="rootRef"
class="entry-rows"
>
<legend class="entry-rows__legend">
{{ legend }}
</legend>
<div
v-for="(row, index) in rows"
:key="row.key"
class="entry-rows__row"
>
<div class="entry-rows__line">
<select
class="form-input entry-rows__kind"
:aria-label="kindLabel(koreanOrdinal(index + 1))"
:value="row.label"
@change="emit('update', index, { label: ($event.target as HTMLSelectElement).value })"
>
<option
v-for="kind in kinds"
:key="kind.value"
:value="kind.value"
>
{{ kind.text }}
</option>
</select>
<input
:id="valueId(row.key)"
class="form-input entry-rows__value"
:type="inputType === 'email' ? 'text' : 'tel'"
:inputmode="inputType === 'email' ? 'email' : 'tel'"
:aria-label="valueLabel(koreanOrdinal(index + 1))"
:value="row.value"
:maxlength="maxLength"
autocomplete="off"
:aria-invalid="errors[index] ? 'true' : 'false'"
:aria-describedby="describedBy(index, row.key)"
@input="emit('update', index, { value: ($event.target as HTMLInputElement).value })"
@blur="emit('leave', index)"
>
<button
type="button"
class="entry-rows__remove"
:aria-label="removeLabel(koreanOrdinal(index + 1))"
:title="removeLabel(koreanOrdinal(index + 1))"
@click="emit('remove', index)"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
<p
v-if="errors[index]"
:id="messageId(row.key)"
class="form-message form-message--error entry-rows__message"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ errors[index] }}</span>
</p>
<!-- 늦게 뜨는 같은 주소 안내 — 늘 있는 알림 영역 안에 그려야 읽힌다 (비면 높이 0) -->
<div
role="status"
class="entry-rows__live"
>
<p
v-if="!errors[index] && warnings[index]"
:id="messageId(row.key)"
class="form-message entry-rows__message entry-rows__message--warning"
data-contact-duplicate
>
<span>
{{ warnings[index]?.text }}
<button
type="button"
class="entry-rows__link"
@click="emit('view', warnings[index]?.contactId ?? '')"
>{{ warnings[index]?.linkLabel }}</button>
</span>
</p>
</div>
</div>
<div class="entry-rows__add">
<button
ref="addRef"
type="button"
class="entry-rows__add-button"
:aria-disabled="canAdd ? undefined : 'true'"
:aria-describedby="hintId"
@click="onAdd"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="1.9"
/>
<span>{{ addLabel }}</span>
</button>
<span
:id="hintId"
class="entry-rows__hint"
>{{ hint }}</span>
</div>
<p
v-if="countError"
:id="countErrorId"
class="form-message form-message--error"
data-entry-count-error
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ countError }}</span>
</p>
</fieldset>
</template>
<style scoped>
.entry-rows {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.entry-rows__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 줄 아래 글은 위 여백 6 — 비어 있는 알림 영역(같은 주소 안내)이 자리를 차지하지 않게 gap 대신 margin 으로 */
.entry-rows__row {
display: flex;
flex-direction: column;
}
/* 종류 112 · 값 · ✕ 36 — 사이 8 (디자인 원본 ContactsEdit) */
.entry-rows__line {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr) var(--size-filter-remove);
align-items: center;
gap: var(--space-8);
}
/* 줄 높이 40 — 밀도 예외 (tokens.css --size-filter-control) */
.entry-rows__kind,
.entry-rows__value {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
}
.entry-rows__kind {
padding: 0 var(--space-8);
}
.entry-rows__value {
padding: 0 var(--space-10);
}
/* ✕ 36×40 — 밀도 예외 (tokens.css --size-filter-remove) */
.entry-rows__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-filter-remove);
height: var(--size-filter-control);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.entry-rows__remove:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 줄 아래 글 — 값 칸과 같은 선에서 (종류 112 + 사이 8) */
.entry-rows__message {
margin-top: var(--space-6);
margin-left: calc(7rem + var(--space-8));
overflow-wrap: anywhere;
}
/* 같은 주소 — 막지 않는 안내라 갈색 (디자인 원본 ContactsEdit #7A5200) */
.entry-rows__message--warning {
color: var(--color-warning-text);
}
.entry-rows__link {
padding: 0;
border: 0;
background: transparent;
color: var(--color-brand);
font: inherit;
font-weight: 600;
text-decoration: underline;
text-underline-offset: var(--space-2);
}
.entry-rows__link:hover {
color: var(--color-brand-strong);
}
.entry-rows__add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
/* [더하기] 40 — 밀도 예외 (필터 창의 [조건 더하기]와 같다) */
.entry-rows__add-button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-filter-control);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.entry-rows__add-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.entry-rows__add-button[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.entry-rows__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,650 @@
<script setup lang="ts">
// 구성원 넣기 창 (디자인 원본 ContactsEdit "‘거래처’에 구성원 넣기" — 모달 620)
// 「내 연락처 | 사내 직원」 전환(36 — 밀도 예외 .segmented) + 찾기(초성도 — 서버 q, 치고 250ms 쉬면) → 체크 줄 48(이름 · 회사 · 주소) →
// 안내 한 줄 → "N명 고름" · [넣기] [취소].
// - 이미 구성원인 사람은 보이지 않는다 — 두 쪽 모두 서버가 거른다(excludeGroupId — 내 연락처는 그 그룹의 구성원, 사내 직원은 구성원이 가진 주소).
// 화면에서 거르지 않으므로 첫 쪽이 모두 구성원이어도 빈 안내가 뜨지 않는다. 다음 쪽(cursor)도 같은 값과 함께 보낸다
// - 고른 것은 전환 · 찾기를 오가도 남는다. 한 번에 100명까지(contactIds · directoryIds 합쳐 — 계약 3절) — 다 고르면 나머지 칸을 끄고 안내
// (안내는 늘 있는 알림 영역 안에 그려 화면 읽기 프로그램이 "N명 고름"에 이어 읽는다)
// - [넣기]: POST /contact-groups/{id}/members — 사내 직원은 서버가 내 주소록에도 만든다(같은 주소의 연락처가 있으면 그것을 넣는다).
// 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다." (useContactActions). 한도(CONTACT_002) · 없는 것(RES_001) · 장애는 인터셉터가 알리고 창은 그대로
// - 목록은 알림 없이 받는다 — 못 받으면 그 자리에 사전 문구(사내 목록은 "사내 목록을 불러오지 못했습니다") + [다시 시도]
// 고정 id — #contact-members-dialog · #contact-members-dialog-title · #contact-members-search · #contact-members-count · #contact-members-add · #contact-members-cancel
import { computed, onBeforeUnmount, onScopeDispose, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useClock, type ClockTimer } from '@/composables/useClock'
import { useContactActions } from '@/composables/useContactActions'
import { useContacts } from '@/composables/useContacts'
import { toApiError, type ApiError } from '@/composables/useApi'
import {
CONTACT_MEMBERS_PICK_MAX,
CONTACT_MEMBERS_TEXT,
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_PAGE_SIZE,
CONTACTS_SEARCH_DELAY_MS,
CONTACTS_SEARCH_MAX_LENGTH,
} from '@/shared/constants/contacts'
import type { ContactGroup, ContactGroupMembersPayload } from '@/shared/types/contacts'
import type { PageInfo } from '@/shared/types/pagination'
import { contactDisplayName, contactListMeta, contactSearchQueryOf, primaryEmail } from '@/shared/utils/contacts'
import { formatCount } from '@/shared/utils/format'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { LoadStatus } from '@/stores/mail.store'
interface Props {
open: boolean
group: ContactGroup
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const contactsApi = useContacts()
const actions = useContactActions()
const clock = useClock()
type Tab = 'mine' | 'staff'
/** 고를 줄 하나 — 내 연락처 · 사내 직원을 같은 모양으로 */
interface CandidateRow {
id: string
name: string
meta: string
email: string | null
}
const tab = ref<Tab>('mine')
/** 찾기 칸에 보이는 글 (한글 조합 중 글자 포함) */
const text = ref<string>('')
/** 서버에 보낸 찾을 글 */
const query = ref<string | null>(null)
const rows = shallowRef<CandidateRow[]>([])
const pageInfo = ref<PageInfo | null>(null)
const status = ref<LoadStatus>('idle')
const listError = ref<ApiError | null>(null)
const loadingMore = ref<boolean>(false)
const pickedContacts = ref<ReadonlySet<string>>(new Set())
const pickedEntries = ref<ReadonlySet<string>>(new Set())
const pending = ref<boolean>(false)
let listSeq = 0
let openToken = 0
let searchTimer: ClockTimer | null = null
let unmounted = false
const title = computed(() => CONTACT_MEMBERS_TEXT.title(props.group.name))
const pickedCount = computed(() => pickedContacts.value.size + pickedEntries.value.size)
const atMax = computed(() => pickedCount.value >= CONTACT_MEMBERS_PICK_MAX)
const hasMore = computed(() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor))
const emptyText = computed(() => {
if (query.value) return CONTACT_MEMBERS_TEXT.searchEmpty(query.value)
return tab.value === 'mine' ? CONTACT_MEMBERS_TEXT.emptyMine : CONTACT_MEMBERS_TEXT.emptyStaff
})
const errorTitle = computed(() =>
tab.value === 'staff' ? CONTACTS_EMPTY_TEXT.directoryErrorTitle : (listError.value?.message ?? ''),
)
function picked(id: string): boolean {
return (tab.value === 'mine' ? pickedContacts.value : pickedEntries.value).has(id)
}
function togglePick(id: string, checked: boolean) {
const target = tab.value === 'mine' ? pickedContacts : pickedEntries
const next = new Set(target.value)
if (checked && !atMax.value) next.add(id)
else next.delete(id)
target.value = next
}
async function fetchRows(cursor: string | null): Promise<{ rows: CandidateRow[]; pageInfo: PageInfo }> {
// 이미 구성원인 사람은 서버가 뺀다 — 다음 쪽(cursor)도 같은 q · excludeGroupId 와 함께 (계약 3절)
const base = {
limit: CONTACTS_PAGE_SIZE,
excludeGroupId: props.group.id,
...(query.value ? { q: query.value } : {}),
...(cursor ? { cursor } : {}),
}
if (tab.value === 'mine') {
const page = await contactsApi.listContacts(base, { silent: true })
return {
rows: page.items.map((contact) => ({
id: contact.id,
name: contactDisplayName(contact),
meta: contactListMeta(contact),
email: contact.name?.trim() ? primaryEmail(contact) : null,
})),
pageInfo: page.pageInfo,
}
}
const page = await contactsApi.listDirectoryEntries({ ...base, kind: 'person' }, { silent: true })
return {
rows: page.items.map((entry) => ({ id: entry.id, name: entry.name, meta: '', email: entry.email })),
pageInfo: page.pageInfo,
}
}
/** 지금 쪽(전환 · 찾을 글)의 첫 쪽을 받는다 — 그 전 요청의 응답은 버린다 */
async function load(): Promise<void> {
listSeq += 1
const seq = listSeq
status.value = 'loading'
listError.value = null
rows.value = []
pageInfo.value = null
loadingMore.value = false
try {
const result = await fetchRows(null)
if (seq !== listSeq) return
rows.value = result.rows
pageInfo.value = result.pageInfo
status.value = 'ready'
} catch (caught) {
if (seq !== listSeq) return
status.value = 'error'
listError.value = toApiError(caught)
}
}
async function loadMore(): Promise<void> {
const cursor = pageInfo.value?.nextCursor ?? null
if (!cursor || loadingMore.value || status.value !== 'ready') return
const seq = listSeq
loadingMore.value = true
try {
const result = await fetchRows(cursor)
if (seq !== listSeq) return
const known = new Set(rows.value.map((row) => row.id))
rows.value = [...rows.value, ...result.rows.filter((row) => !known.has(row.id))]
pageInfo.value = result.pageInfo
} catch {
// 알리지 않는다 — [더 보기]를 다시 누르면 된다
} finally {
if (seq === listSeq) loadingMore.value = false
}
}
function setTab(next: Tab) {
if (tab.value === next) return
tab.value = next
void load()
}
function cancelSearch() {
if (searchTimer !== null) clock.clearTimeout(searchTimer)
searchTimer = null
}
/** 찾을 글을 보낸다 — 조합 중(글자와 자모가 섞임)이면 보내지 않고 지난 결과를 둔다 */
function applySearch() {
searchTimer = null
const next = contactSearchQueryOf(text.value)
if (next === undefined || next === query.value) return
query.value = next
void load()
}
function onSearchInput(event: Event) {
text.value = (event.target as HTMLInputElement).value
cancelSearch()
searchTimer = clock.setTimeout(applySearch, CONTACTS_SEARCH_DELAY_MS)
}
function onSearchKeydown(event: KeyboardEvent) {
if (isImeComposing(event) || event.key !== 'Enter') return
// Enter 는 바로 찾는다 — 창의 [넣기]를 누르지 않는다
event.preventDefault()
cancelSearch()
applySearch()
}
async function submit() {
if (pending.value || pickedCount.value === 0) return
const payload: ContactGroupMembersPayload = {
...(pickedContacts.value.size > 0 ? { contactIds: [...pickedContacts.value] } : {}),
...(pickedEntries.value.size > 0 ? { directoryIds: [...pickedEntries.value] } : {}),
}
pending.value = true
const token = openToken
try {
const added = await actions.addMembers(props.group, payload)
if (token !== openToken || unmounted) return
pending.value = false
if (added) emit('close')
} catch {
// 인터셉터가 알렸다 — 고른 것은 그대로 두고 목록을 다시 받는다 (그사이 지워진 연락처가 있을 수 있다)
if (token !== openToken || unmounted) return
pending.value = false
void load()
}
}
/** 열 때마다 처음부터 — 내 연락처 쪽 · 빈 찾기 · 고른 것 없음 */
function reset() {
openToken += 1
cancelSearch()
tab.value = 'mine'
text.value = ''
query.value = null
pickedContacts.value = new Set()
pickedEntries.value = new Set()
pending.value = false
void load()
}
watch(
[() => props.open, () => props.group.id],
([open]) => {
if (open) reset()
},
{ immediate: true },
)
onScopeDispose(cancelSearch)
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<ModalDialog
id="contact-members-dialog"
:open="open"
:title="title"
title-id="contact-members-dialog-title"
described-by="contact-members-note"
wide
@cancel="emit('close')"
>
<div class="members">
<div class="members__bar">
<div
class="segmented segmented--outline"
role="group"
:aria-label="CONTACT_MEMBERS_TEXT.sourceLabel"
>
<button
type="button"
class="segmented__item"
:aria-pressed="tab === 'mine'"
data-members-tab="mine"
@click="setTab('mine')"
>
{{ CONTACT_MEMBERS_TEXT.mine }}
</button>
<button
type="button"
class="segmented__item"
:aria-pressed="tab === 'staff'"
data-members-tab="staff"
@click="setTab('staff')"
>
{{ CONTACT_MEMBERS_TEXT.staff }}
</button>
</div>
<div class="members__search">
<AppIcon
name="search"
:size="15"
class="members__search-icon"
/>
<input
id="contact-members-search"
type="search"
class="members__search-input"
:aria-label="CONTACT_MEMBERS_TEXT.search"
:placeholder="CONTACT_MEMBERS_TEXT.searchPlaceholder"
:value="text"
:maxlength="CONTACTS_SEARCH_MAX_LENGTH"
autocomplete="off"
autofocus
@input="onSearchInput"
@compositionupdate="onSearchInput"
@keydown="onSearchKeydown"
>
</div>
</div>
<div
class="members__list-box"
:aria-busy="status === 'loading' ? 'true' : undefined"
>
<p
v-if="status === 'loading'"
class="members__state"
>
{{ CONTACT_MEMBERS_TEXT.loading }}
</p>
<div
v-else-if="status === 'error'"
class="members__state members__state--error"
role="alert"
>
<p class="members__state-title">
{{ errorTitle }}
</p>
<button
type="button"
class="members__retry"
@click="load"
>
{{ CONTACTS_LIST_TEXT.retry }}
</button>
</div>
<p
v-else-if="rows.length === 0"
class="members__state"
>
{{ emptyText }}
</p>
<ul
v-else
class="members__list"
:aria-label="tab === 'mine' ? CONTACT_MEMBERS_TEXT.mine : CONTACT_MEMBERS_TEXT.staff"
>
<li
v-for="row in rows"
:key="row.id"
class="members__item"
>
<label class="members__row">
<input
type="checkbox"
class="members__check"
:checked="picked(row.id)"
:disabled="atMax && !picked(row.id)"
:data-members-pick="row.id"
@change="togglePick(row.id, ($event.target as HTMLInputElement).checked)"
>
<span class="members__text">
<span class="members__name">
{{ row.name }}
<span
v-if="row.meta"
class="members__meta"
>{{ row.meta }}</span>
</span>
<span
v-if="row.email"
class="members__email"
>{{ row.email }}</span>
</span>
</label>
</li>
<li
v-if="hasMore"
class="members__more-item"
>
<button
type="button"
class="members__more"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? CONTACTS_LIST_TEXT.loadingMore : CONTACT_MEMBERS_TEXT.more }}
</button>
</li>
</ul>
</div>
<div class="members__notes">
<p
id="contact-members-note"
class="members__note"
>
{{ CONTACT_MEMBERS_TEXT.note }}
</p>
<!-- 100명을 다 골랐다 — 늘 있는 알림 영역 안에 그려야 읽힌다 (비면 높이 0 — 위 여백은 글이 갖는다) -->
<div role="status">
<p
v-if="atMax"
class="form-message members__max"
data-members-max
>
{{ CONTACT_MEMBERS_TEXT.pickMax }}
</p>
</div>
</div>
</div>
<template #actions>
<span
id="contact-members-count"
class="members__count"
role="status"
>{{ CONTACT_MEMBERS_TEXT.picked(formatCount(pickedCount)) }}</span>
<button
id="contact-members-add"
type="button"
class="modal-dialog__action members__add"
:disabled="pickedCount === 0"
:aria-disabled="pending ? 'true' : undefined"
:aria-busy="pending ? 'true' : undefined"
@click="submit"
>
{{ CONTACT_MEMBERS_TEXT.add }}
</button>
<button
id="contact-members-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('close')"
>
{{ CONTACT_MEMBERS_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.members {
display: flex;
flex-direction: column;
gap: var(--space-12);
margin-top: var(--space-4);
}
/* 안내와 사이 12 — 전에 묶음 사이(gap)였던 값 그대로 */
.members__max {
margin-top: var(--space-12);
}
.members__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-10);
}
/* 찾기 칸 40 — 밀도 예외 (필터 창의 조건 칸과 같다, tokens.css --size-filter-control) */
.members__search {
flex: 1 1 14rem;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
height: var(--size-filter-control);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.members__search:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.members__search-icon {
color: var(--color-text-muted);
}
.members__search-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
}
.members__search-input:focus,
.members__search-input:focus-visible {
outline: none;
}
/* 목록 상자 — 줄이 많으면 안에서 스크롤한다 (6줄 남짓) */
.members__list-box {
max-height: 18rem;
overflow-y: auto;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.members__list {
margin: 0;
padding: 0;
list-style: none;
}
.members__item + .members__item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 체크 줄 48 — 줄 전체가 눌린다 (디자인 원본 ContactsEdit) */
.members__row {
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--space-48);
padding: var(--space-4) var(--space-12);
cursor: pointer;
}
.members__row:hover {
background: var(--color-surface-panel);
}
.members__check {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
.members__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.members__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.members__meta {
margin-left: var(--space-4);
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
}
.members__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
.members__more-item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.members__more {
width: 100%;
min-height: var(--size-touch);
border: 0;
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.members__more:hover:not(:disabled) {
background: var(--color-brand-subtle);
}
.members__more:disabled {
color: var(--color-text-muted);
}
.members__state {
margin: 0;
padding: var(--space-24) var(--space-16);
font-size: var(--font-size-label);
color: var(--color-text-muted);
text-align: center;
}
.members__state--error {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.members__state-title {
margin: 0;
font-weight: 600;
color: var(--color-text);
}
.members__retry {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
font-weight: 600;
}
.members__retry:hover {
background: var(--color-surface-muted);
}
.members__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* "N명 고름" — 단추 줄 왼쪽 */
.members__count {
margin-right: auto;
align-self: center;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.modal-dialog__action.members__add {
padding: 0 var(--space-18);
}
.modal-dialog__action.members__add[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,213 @@
<script setup lang="ts">
// 새 그룹 · 그룹 이름 바꾸기 창 (디자인 원본 ContactsEdit "새 그룹" — 모달 400, 공용 모양 ModalDialog 의 medium)
// 칸 하나(그룹 이름 44 · 50자) + [만들기] / [저장] [취소]. 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
// - 같은 이름이면 칸 아래 사전 문구(CONTACT_001 — 알림 없이), 형식(VAL_001)도 칸 아래. 그 밖의 실패(그룹 100개 CONTACT_002 · 장애)는 인터셉터의 알림
// - 이름을 비웠으면 누를 수 없다(보내는 동안은 aria-disabled — 누른 단추가 초점을 잃지 않게). 이름 바꾸기에서 그대로면 보내지 않고 닫는다. Enter 는 [만들기 · 저장]과 같다(한글 조합 중의 Enter 는 글자 확정)
// - 그룹이 그사이 사라졌으면(이름 바꾸기 404 RES_001 — 알림은 인터셉터) 닫고 그룹을 다시 맞춘다
// - 기다리는 사이 창을 닫았거나(취소 · Esc) 창이 사라졌으면 칸 아래에 보일 거절을 알림으로 알린다 (필터 고치기 창과 같다)
// 고정 id — #contact-group-dialog · #contact-group-dialog-title · #contact-group-name · #contact-group-name-error · #contact-group-confirm · #contact-group-cancel
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { CONTACT_GROUP_NAME_INLINE_CODES, useContactActions } from '@/composables/useContactActions'
import { notifyApiError, toApiError } from '@/composables/useApi'
import { CONTACT_GROUP_DIALOG_TEXT, CONTACT_GROUP_NAME_MAX_LENGTH } from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
/** 이름을 바꿀 그룹 — null 이면 새 그룹 */
group: ContactGroup | null
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const actions = useContactActions()
const contactsStore = useContactsStore()
const inputRef = ref<HTMLInputElement | null>(null)
const name = ref<string>(props.group?.name ?? '')
const pending = ref<boolean>(false)
/** 칸 아래 거절 — 같은 이름 · 형식 (사전 문구) */
const error = ref<string | null>(null)
let openToken = 0
let unmounted = false
const title = computed(() =>
props.group ? CONTACT_GROUP_DIALOG_TEXT.renameTitle : CONTACT_GROUP_DIALOG_TEXT.createTitle,
)
const confirmLabel = computed(() => (props.group ? CONTACT_GROUP_DIALOG_TEXT.save : CONTACT_GROUP_DIALOG_TEXT.create))
const canSubmit = computed(() => name.value.trim() !== '' && !pending.value)
function onInput(event: Event) {
name.value = (event.target as HTMLInputElement).value
error.value = null
}
async function submit() {
if (!canSubmit.value) return
const trimmed = name.value.trim()
const group = props.group
if (group && trimmed === group.name) {
emit('close')
return
}
pending.value = true
error.value = null
const token = openToken
/** 보낸 창이 아직 열려 있다 — 칸 아래에 보일 수 있다 */
const isCurrent = () => token === openToken && !unmounted && props.open
try {
const saved = group ? await actions.renameGroup(group, trimmed) : await actions.createGroup(trimmed)
if (!isCurrent()) return
pending.value = false
if (saved) emit('close')
} catch (caught) {
const apiError = toApiError(caught)
const inline = CONTACT_GROUP_NAME_INLINE_CODES.includes(apiError.code)
if (!isCurrent()) {
if (inline) notifyApiError(apiError)
return
}
pending.value = false
if (inline) {
error.value = apiError.message
void nextTick(() => inputRef.value?.focus())
return
}
if (apiError.code === 'RES_001') {
void contactsStore.loadGroups()
emit('close')
}
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || isImeComposing(event)) return
event.preventDefault()
void submit()
}
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
watch(
() => [props.open, props.group] as const,
([open]) => {
if (!open) return
openToken += 1
name.value = props.group?.name ?? ''
error.value = null
pending.value = false
void nextTick(() => {
if (props.group) inputRef.value?.select()
})
},
{ immediate: true },
)
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<ModalDialog
id="contact-group-dialog"
:open="open"
:title="title"
title-id="contact-group-dialog-title"
:described-by="error ? 'contact-group-name-error' : undefined"
medium
@cancel="emit('close')"
>
<div class="group-name">
<label
for="contact-group-name"
class="group-name__label"
>{{ CONTACT_GROUP_DIALOG_TEXT.name }}</label>
<input
id="contact-group-name"
ref="inputRef"
class="form-input group-name__input"
type="text"
:value="name"
:maxlength="CONTACT_GROUP_NAME_MAX_LENGTH"
autocomplete="off"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? 'contact-group-name-error' : undefined"
autofocus
@input="onInput"
@keydown="onKeydown"
>
<p
v-if="error"
id="contact-group-name-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</div>
<template #actions>
<button
id="contact-group-confirm"
type="button"
class="modal-dialog__action group-name__confirm"
:disabled="name.trim() === ''"
:aria-disabled="pending ? 'true' : undefined"
:aria-busy="pending ? 'true' : undefined"
@click="submit"
>
{{ confirmLabel }}
</button>
<button
id="contact-group-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('close')"
>
{{ CONTACT_GROUP_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.group-name {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-top: var(--space-4);
}
.group-name__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.group-name__input {
width: 100%;
}
.modal-dialog__action.group-name__confirm {
padding: 0 var(--space-18);
}
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.modal-dialog__action.group-name__confirm[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
// 연락처 상세 머리 줄 끝의 ⋯ (44×44 — 디자인 원본 Contacts · ContactsEdit "⋯ 메뉴") — 주소 복사 · 연락처 지우기(빨간 글)
// 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 메일 주소가 없는 연락처는 「주소 복사」를 끄고 까닭을 아래에 (aria-disabled — 화살표로 가서 듣는다)
// 지우기는 확인 없이 — 알림의 [되돌리기] (useContactActions)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useContactActions } from '@/composables/useContactActions'
import { CONTACT_DETAIL_TEXT, CONTACT_MENU_TEXT } from '@/shared/constants/contacts'
import type { Contact } from '@/shared/types/contacts'
import { primaryEmail } from '@/shared/utils/contacts'
interface Props {
contact: Contact
}
const props = defineProps<Props>()
const actions = useContactActions()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const address = computed(() => primaryEmail(props.contact))
const items = computed<EditorMenuItem[]>(() => [
{
id: 'copy',
label: CONTACT_MENU_TEXT.copy,
icon: 'copy',
disabled: !address.value,
note: address.value ? undefined : CONTACT_MENU_TEXT.copyEmptyNote,
},
{ id: 'delete', label: CONTACT_MENU_TEXT.delete, icon: 'trash', tone: 'danger' },
])
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
open.value = true
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
open.value = true
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) buttonRef.value?.focus()
}
function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'copy' && address.value) void actions.copyAddress(address.value)
if (id === 'delete') void actions.removeContact(props.contact)
}
</script>
<template>
<div class="contact-more">
<button
ref="buttonRef"
type="button"
class="contact-more__button"
:aria-label="CONTACT_DETAIL_TEXT.more"
:title="CONTACT_DETAIL_TEXT.more"
aria-haspopup="menu"
:aria-expanded="open"
data-contact-more
@click="onClick"
@keydown="onKeydown"
>
<AppIcon name="more-horizontal" />
</button>
<EditorMenu
v-if="open"
class="contact-more__menu"
:label="CONTACT_DETAIL_TEXT.menu"
:items="items"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
.contact-more {
position: relative;
}
/* 44×44 (디자인 원본 Contacts) */
.contact-more__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
}
.contact-more__button:hover,
.contact-more__button[aria-expanded='true'] {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 메뉴 폭 220 (디자인 원본 ContactsEdit) */
.contact-more__menu {
min-width: 13.75rem;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
// 주소록 화면의 창 묶음 — 패널 · 목록 · 상세가 연 창을 그린다 (useContactsDialogs · 메일함 패널의 MailboxDialogs 와 같은 결)
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog)
// - 창은 한 번 그리면 두고 열고 닫기만 한다 — 닫을 때 초점이 연 단추로 돌아가게(ModalDialog). 닫히는 동안 글이 바뀌지 않게 마지막 요청을 쥐고 있는다
// - 닫은 뒤 연 단추가 사라져(지운 연락처의 [고치기] 등) 초점을 잃었으면 목록 칸으로 옮긴다
import { computed, nextTick, shallowRef, watch } from 'vue'
import ContactDialog from '@/components/contacts/ContactDialog.vue'
import ContactGroupMembersDialog from '@/components/contacts/ContactGroupMembersDialog.vue'
import ContactGroupNameDialog from '@/components/contacts/ContactGroupNameDialog.vue'
import type { ContactsDialogRequest } from '@/composables/useContactsDialogs'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { ContactPrefill } from '@/shared/utils/contacts'
interface Props {
request: ContactsDialogRequest | null
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 목록 칸 (ContactsList 의 data-contacts-list) */
const CONTACTS_LIST_SELECTOR = '[data-contacts-list]'
/** 창마다 마지막으로 받은 요청 — 닫혀도 남겨 둔다 (닫히는 동안 칸 값이 바뀌지 않게) */
const contactTarget = shallowRef<{ contact: Contact | null; prefill: ContactPrefill | null }>({
contact: null,
prefill: null,
})
const nameTarget = shallowRef<ContactGroup | null>(null)
const membersTarget = shallowRef<ContactGroup | null>(null)
const kind = computed(() => props.request?.kind ?? null)
function close() {
emit('close')
void nextTick(() => {
const active = document.activeElement
if (active === null || active === document.body || !active.isConnected) {
document.querySelector<HTMLElement>(CONTACTS_LIST_SELECTOR)?.focus({ preventScroll: true })
}
})
}
watch(
() => props.request,
(request) => {
if (request?.kind === 'contact') contactTarget.value = { contact: request.contact, prefill: request.prefill }
else if (request?.kind === 'group-name') nameTarget.value = request.group
else if (request?.kind === 'members') membersTarget.value = request.group
},
{ immediate: true },
)
</script>
<template>
<ContactDialog
:open="kind === 'contact'"
:contact="contactTarget.contact"
:prefill="contactTarget.prefill"
@close="close"
/>
<ContactGroupNameDialog
:open="kind === 'group-name'"
:group="nameTarget"
@close="close"
/>
<ContactGroupMembersDialog
v-if="membersTarget"
:open="kind === 'members'"
:group="membersTarget"
@close="close"
/>
</template>
@@ -0,0 +1,151 @@
<script setup lang="ts">
// 주소록 패널의 그룹 한 줄 — 아이콘 · 이름 · 구성원 수(평범한 숫자, 뱃지 아님) · ⋯ (디자인 원본 Contacts 왼쪽 · ContactsEdit "⋯ 메뉴")
// - ⋯ 는 메일함 줄과 같은 단추(MailboxMoreButton — 32×40 밀도 예외): 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘)
// - 메뉴: 이름 바꾸기 · 그룹에 메일 쓰기(구성원이 없으면 끄고 까닭 — aria-disabled) · 그룹 지우기(빨간 글)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { ContactGroupAction } from '@/components/contacts/contacts-ui'
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { CONTACT_GROUP_MENU_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import { contactsListLocation } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
interface Props {
group: ContactGroup
/** 지금 보는 그룹 */
active: boolean
}
interface Emits {
(e: 'action', action: ContactGroupAction, group: ContactGroup): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const ACTIONS: readonly ContactGroupAction[] = ['rename', 'compose', 'delete']
const menuOpen = ref<boolean>(false)
const empty = computed(() => props.group.memberCount <= 0)
const items = computed<EditorMenuItem[]>(() => [
{ id: 'rename', label: CONTACT_GROUP_MENU_TEXT.rename, icon: 'edit-square' },
{
id: 'compose',
label: CONTACT_GROUP_MENU_TEXT.compose,
icon: 'edit',
disabled: empty.value,
note: empty.value ? CONTACT_GROUP_MENU_TEXT.composeEmptyNote : undefined,
},
{ id: 'delete', label: CONTACT_GROUP_MENU_TEXT.delete, icon: 'trash', tone: 'danger' },
])
const location = computed(() => contactsListLocation({ kind: 'group', groupId: props.group.id }))
function onSelect(id: string) {
const action = ACTIONS.find((candidate) => candidate === id)
if (!action || (action === 'compose' && empty.value)) return
emit('action', action, props.group)
}
</script>
<template>
<li
:class="['group-row', { 'is-active': active, 'is-open': menuOpen }]"
:data-group-row="group.id"
>
<RouterLink
:to="location"
class="group-row__link"
:aria-current="active ? 'page' : undefined"
>
<AppIcon name="users" />
<span class="group-row__name">{{ group.name }}</span>
<span class="group-row__count">{{ formatCount(group.memberCount) }}</span>
</RouterLink>
<MailboxMoreButton
class="group-row__more"
:mailbox-name="group.name"
:label="CONTACTS_PANEL_TEXT.groupMore(group.name)"
:menu-label="CONTACTS_PANEL_TEXT.groupMenu(group.name)"
:items="items"
@select="onSelect"
@open-change="menuOpen = $event"
/>
</li>
</template>
<style scoped>
.group-row {
display: flex;
align-items: center;
border-radius: var(--radius-item);
}
.group-row:hover {
background: var(--color-surface-muted);
}
.group-row.is-active {
background: var(--color-brand-subtle);
}
/* 줄 40 — 메일함 패널의 줄과 같다 (tokens.css --size-row-min) */
.group-row__link {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-4) 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.group-row:hover .group-row__link {
color: var(--color-text);
}
.group-row.is-active .group-row__link {
color: var(--color-brand-strong);
font-weight: 600;
}
.group-row__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 구성원 수 — 흐린 평범한 숫자 (디자인 원본 Contacts) */
.group-row__count {
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 (메일함 줄과 같다) */
.group-row__more {
opacity: 0;
}
.group-row:hover .group-row__more,
.group-row:focus-within .group-row__more,
.group-row.is-open .group-row__more {
opacity: 1;
}
@media (hover: none) {
.group-row__more {
opacity: 1;
}
}
</style>
@@ -0,0 +1,523 @@
<script setup lang="ts">
// 주소록 목록 칸 (384 — 디자인 원본 Contacts 가운데 · README "8단계 › 주소록 화면")
// - 머리(스크롤해도 위에 남는다): 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기]
// (구성원이 없는 그룹이면 [그룹에 메일 쓰기]를 aria-disabled + 까닭 — 그룹 ⋯ 메뉴와 같다. 구성원을 받는 동안 aria-busy).
// 사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — …" · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
// - 줄(ContactsListItem) — 이름 차례(서버), 100명씩 받고 끝에 [더 보기]
// - 상태: 불러오는 중(뼈대) · 못 받음(사내 목록은 "사내 목록을 불러오지 못했습니다" + [다시 시도], 없는 그룹은 [전체 연락처로]) ·
// 찾은 것 없음 · 처음(연락처 없음 — [연락처 추가]) · 빈 그룹([구성원 넣기]) · 빈 사내 목록 (디자인 원본 ContactsEdit "상태")
// data-contacts-list — 지운 뒤 · 창을 닫은 뒤 초점을 잃으면 여기로 (useContactActions · ContactsDialogs)
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ContactsListItem from '@/components/contacts/ContactsListItem.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { useContactActions } from '@/composables/useContactActions'
import { useContactsDialogs } from '@/composables/useContactsDialogs'
import {
CONTACT_GROUP_MENU_TEXT,
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_PANEL_TEXT,
DIRECTORY_KIND_TEXT,
} from '@/shared/constants/contacts'
import { contactDisplayName, contactListMeta, primaryEmail } from '@/shared/utils/contacts'
import { contactsItemLocation, contactsListLocation, type ContactsRouteState } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
state: ContactsRouteState
}
const props = defineProps<Props>()
const store = useContactsStore()
const dialogs = useContactsDialogs()
const actions = useContactActions()
const router = useRouter()
/** 목록 줄 — 연락처 · 사내 항목을 같은 모양으로 */
interface ListRow {
id: string
name: string
meta: string
email: string | null
fallback: string
shared: boolean
}
const source = computed(() => props.state.source)
const directoryKind = computed(() => (source.value.kind === 'directory' ? source.value.directoryKind : null))
const group = computed(() => (source.value.kind === 'group' ? store.groupById(source.value.groupId) : null))
const title = computed(() => {
if (source.value.kind === 'group') return group.value?.name ?? CONTACTS_PANEL_TEXT.groups
if (directoryKind.value) return DIRECTORY_KIND_TEXT[directoryKind.value]
return CONTACTS_PANEL_TEXT.all
})
const shared = computed(() => directoryKind.value === 'shared')
const ready = computed(() => store.listStatus === 'ready')
const countText = computed(() => {
const total = store.pageInfo?.total
if (!ready.value || typeof total !== 'number') return ''
const count = formatCount(total)
return props.state.q ? CONTACTS_LIST_TEXT.found(count, shared.value) : CONTACTS_LIST_TEXT.count(count, shared.value)
})
const rows = computed<ListRow[]>(() => {
if (directoryKind.value) {
return store.entries.map((entry) => ({
id: entry.id,
name: entry.name,
meta: entry.kind === 'shared' ? DIRECTORY_KIND_TEXT.shared : '',
email: entry.email,
fallback: entry.email,
shared: entry.kind === 'shared',
}))
}
return store.contacts.map((contact) => ({
id: contact.id,
name: contactDisplayName(contact),
meta: contactListMeta(contact),
// 이름이 없으면 이름 자리에 주소를 보였다 — 둘째 줄은 비운다
email: contact.name?.trim() ? primaryEmail(contact) : null,
fallback: primaryEmail(contact) ?? '',
shared: false,
}))
})
const directoryNote = computed(() => (directoryKind.value ? CONTACTS_LIST_TEXT.directoryNote[directoryKind.value] : null))
/** 구성원이 없는 그룹 — [그룹에 메일 쓰기]를 끈다 */
const groupEmpty = computed(() => group.value !== null && group.value.memberCount <= 0)
/** 이 그룹의 구성원을 받는 중 — [그룹에 메일 쓰기]의 바쁜 표시 */
const composing = computed(() => group.value !== null && store.composingGroupId === group.value.id)
/** 없는 그룹 — 다른 곳에서 지웠거나 주소에 남은 옛 id */
const missingGroup = computed(() => source.value.kind === 'group' && store.listError?.code === 'RES_001')
function itemLocation(id: string) {
return contactsItemLocation(source.value, id, props.state.q)
}
function addMembers() {
if (group.value) dialogs.openMembers(group.value)
}
function composeGroup() {
if (group.value && !groupEmpty.value) void actions.composeToGroup(group.value)
}
function retry() {
void store.openList(source.value, props.state.q)
}
function leaveToAll() {
void router.replace(contactsListLocation({ kind: 'all' }))
}
function addContact() {
if (!store.isFull) dialogs.openContact(null)
}
</script>
<template>
<section
class="contacts-list"
aria-labelledby="contacts-list-title"
:aria-busy="store.listStatus === 'loading' || store.loadingMore"
tabindex="-1"
data-contacts-list
>
<div class="contacts-list__top">
<div class="contacts-list__head">
<h1
id="contacts-list-title"
class="contacts-list__title"
>
{{ title }}
</h1>
<span
class="contacts-list__count"
data-contacts-count
>{{ countText }}</span>
<div
v-if="group"
class="contacts-list__actions"
>
<MailToolButton
icon="plus"
:label="CONTACTS_LIST_TEXT.addMembers"
:icon-stroke-width="1.9"
data-contacts-add-members
@click="addMembers"
/>
<MailToolButton
icon="edit"
:label="CONTACTS_LIST_TEXT.composeGroup"
:unavailable="groupEmpty"
:busy="composing"
:describedby="groupEmpty ? 'contacts-compose-group-note' : undefined"
:title="groupEmpty ? CONTACT_GROUP_MENU_TEXT.composeEmptyNote : undefined"
data-contacts-compose-group
@click="composeGroup"
/>
<span
v-if="groupEmpty"
id="contacts-compose-group-note"
class="visually-hidden"
>{{ CONTACT_GROUP_MENU_TEXT.composeEmptyNote }}</span>
</div>
</div>
<p
v-if="directoryNote"
class="contacts-list__note"
>
{{ directoryNote }}
</p>
</div>
<ul
v-if="store.listStatus === 'loading' || store.listStatus === 'idle'"
class="contacts-list__skeleton"
:aria-label="CONTACTS_LIST_TEXT.loading"
>
<li
v-for="row in 6"
:key="row"
class="contacts-list__skeleton-row"
>
<span class="contacts-list__skeleton-avatar" />
<span class="contacts-list__skeleton-lines">
<span class="contacts-list__skeleton-bar" />
<span class="contacts-list__skeleton-bar contacts-list__skeleton-bar--short" />
</span>
</li>
</ul>
<StateMessage
v-else-if="store.listStatus === 'error' && directoryKind"
icon="alert-circle"
tone="danger"
:title="CONTACTS_EMPTY_TEXT.directoryErrorTitle"
:hint="CONTACTS_EMPTY_TEXT.directoryErrorHint"
:trace-id="store.listError?.traceId"
>
<button
type="button"
class="contacts-list__outline-button"
@click="retry"
>
{{ CONTACTS_LIST_TEXT.retry }}
</button>
</StateMessage>
<StateMessage
v-else-if="store.listStatus === 'error' && store.listError"
icon="alert-circle"
tone="danger"
:title="store.listError.message"
:trace-id="store.listError.traceId"
>
<button
type="button"
class="contacts-list__text-button"
@click="missingGroup ? leaveToAll() : retry()"
>
{{ missingGroup ? CONTACTS_LIST_TEXT.leaveToAll : CONTACTS_LIST_TEXT.retry }}
</button>
</StateMessage>
<template v-else-if="rows.length > 0">
<ul class="contacts-list__items">
<ContactsListItem
v-for="row in rows"
:key="row.id"
:to="itemLocation(row.id)"
:name="row.name"
:meta="row.meta"
:email="row.email"
:fallback="row.fallback"
:shared="row.shared"
:selected="row.id === state.selectedId"
:data-contacts-row="row.id"
/>
</ul>
<div
v-if="store.hasMore"
class="contacts-list__more"
>
<button
type="button"
class="contacts-list__more-button"
:disabled="store.loadingMore"
@click="store.loadMore()"
>
{{ store.loadingMore ? CONTACTS_LIST_TEXT.loadingMore : CONTACTS_LIST_TEXT.more }}
</button>
</div>
</template>
<StateMessage
v-else-if="state.q"
icon="search"
:title="CONTACTS_EMPTY_TEXT.searchTitle(state.q, Boolean(directoryKind))"
:hint="directoryKind ? CONTACTS_EMPTY_TEXT.directorySearchHint : CONTACTS_EMPTY_TEXT.searchHint"
data-contacts-empty="search"
/>
<StateMessage
v-else-if="source.kind === 'group'"
icon="users"
:title="CONTACTS_EMPTY_TEXT.groupTitle(title)"
:hint="CONTACTS_EMPTY_TEXT.groupHint"
data-contacts-empty="group"
>
<button
v-if="group"
type="button"
class="contacts-list__primary-button"
@click="addMembers"
>
{{ CONTACTS_LIST_TEXT.addMembers }}
</button>
</StateMessage>
<StateMessage
v-else-if="directoryKind"
:icon="directoryKind === 'person' ? 'user' : 'mail'"
:title="CONTACTS_EMPTY_TEXT.directoryTitle[directoryKind]"
data-contacts-empty="directory"
/>
<StateMessage
v-else-if="source.kind === 'all'"
icon="book"
:title="CONTACTS_EMPTY_TEXT.firstTitle"
:hint="CONTACTS_EMPTY_TEXT.firstHint"
data-contacts-empty="first"
>
<button
type="button"
class="contacts-list__primary-button"
:aria-disabled="store.isFull ? 'true' : undefined"
@click="addContact"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<p class="contacts-list__first-note">
{{ CONTACTS_EMPTY_TEXT.firstNote }}
</p>
</StateMessage>
</section>
</template>
<style scoped>
.contacts-list {
flex: 1 1 24rem;
min-width: 0;
display: flex;
flex-direction: column;
border-right: var(--border-width) solid var(--color-border);
}
/* 프로그램이 준 초점(지운 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
.contacts-list:focus {
outline: none;
}
/* 머리 묶음 — 스크롤해도 위에 남는다 */
.contacts-list__top {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-surface);
}
/* 머리 52 — 안쪽 4 8 4 16 (디자인 원본 Contacts) */
.contacts-list__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border);
}
.contacts-list__title {
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts-list__count {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.contacts-list__actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* 사내 목록 — 머리 아래 회색 줄 (12.5px) */
.contacts-list__note {
margin: 0;
padding: var(--space-8) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.contacts-list__items {
margin: 0;
padding: 0;
list-style: none;
}
.contacts-list__more {
padding: var(--space-8) var(--space-12) var(--space-16);
}
/* [더 보기] 44 */
.contacts-list__more-button {
width: 100%;
min-height: var(--size-touch);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.contacts-list__more-button:hover:not(:disabled) {
background: var(--color-brand-subtle);
}
.contacts-list__more-button:disabled {
color: var(--color-text-muted);
}
/* 불러오는 중 — 줄 56 의 뼈대 */
.contacts-list__skeleton {
margin: 0;
padding: 0;
list-style: none;
}
.contacts-list__skeleton-row {
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--space-56);
padding: var(--space-6) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.contacts-list__skeleton-avatar {
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border-radius: var(--radius-round);
background: var(--color-border-subtle);
}
.contacts-list__skeleton-lines {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.contacts-list__skeleton-bar {
width: 55%;
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-skeleton);
}
.contacts-list__skeleton-bar--short {
width: 35%;
background: var(--color-border-subtle);
}
/* 상태 안의 단추 — 채운 단추([연락처 추가] · [구성원 넣기]) · 테두리 단추(사내 목록 [다시 시도]) · 글 단추 */
.contacts-list__primary-button {
display: inline-flex;
align-items: center;
gap: var(--space-8);
margin-top: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.contacts-list__primary-button:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
.contacts-list__primary-button[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
.contacts-list__outline-button {
margin-top: var(--space-4);
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
font-weight: 600;
}
.contacts-list__outline-button:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.contacts-list__text-button {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.contacts-list__text-button:hover {
background: var(--color-brand-subtle);
}
.contacts-list__first-note {
margin: var(--space-4) 0 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.contacts-list {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 주소록 목록의 한 줄 (56 — 디자인 원본 Contacts 가운데): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소.
// 고른 줄은 청록 바탕(aria-current). 누르면 그 연락처를 고른 주소로 간다 (기록이 쌓인다 — 뒤로 가기로 앞서 고른 연락처)
import type { RouteLocationRaw } from 'vue-router'
import ContactAvatar from '@/components/contacts/ContactAvatar.vue'
interface Props {
to: RouteLocationRaw
name: string
/** 이름 옆 작은 글 — "회사 · 부서" (공용 주소는 "공용 주소") */
meta?: string
/** 둘째 줄 — 대표 주소 (이름 자리에 주소를 보였으면 없다) */
email?: string | null
/** 아바타 첫 글자의 대신 글 — 이름이 없을 때 */
fallback?: string
shared?: boolean
selected?: boolean
}
withDefaults(defineProps<Props>(), {
meta: '',
email: null,
fallback: '',
shared: false,
selected: false,
})
</script>
<template>
<li :class="['contacts-row', { 'is-selected': selected }]">
<RouterLink
:to="to"
class="contacts-row__link"
:aria-current="selected ? 'true' : undefined"
>
<ContactAvatar
:name="name"
:fallback="fallback"
:tone="shared ? 'neutral' : 'brand'"
/>
<span class="contacts-row__text">
<span class="contacts-row__top">
<span class="contacts-row__name">{{ name }}</span>
<span
v-if="meta"
class="contacts-row__meta"
>{{ meta }}</span>
</span>
<span
v-if="email"
class="contacts-row__email"
>{{ email }}</span>
</span>
</RouterLink>
</li>
</template>
<style scoped>
.contacts-row {
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.contacts-row:hover {
background: var(--color-surface-panel);
}
.contacts-row.is-selected {
background: var(--color-brand-subtle);
}
/* 줄 56 — 아바타 32 · 사이 12 · 안쪽 7 16 (디자인 원본 Contacts) */
.contacts-row__link {
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--space-56);
padding: var(--space-6) var(--space-16);
color: inherit;
text-decoration: none;
}
.contacts-row__link:hover {
color: inherit;
}
.contacts-row__link:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.contacts-row__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-heading);
}
.contacts-row__top {
min-width: 0;
display: flex;
align-items: baseline;
gap: var(--space-8);
}
.contacts-row__name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.contacts-row__meta {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.contacts-row__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,401 @@
<script setup lang="ts">
// 주소록 모듈 패널 (208 — 디자인 원본 Contacts 왼쪽 · README "8단계 › 주소록 화면") — 메일함 패널과 같은 바탕 · 간격
// [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수).
// - 수는 뱃지가 아니라 오른쪽의 흐린 평범한 숫자. 모르면(못 받음) 숫자를 비운다 — 사내 목록을 받지 못하면 사내 묶음에 숫자가 없다
// - 연락처 3,000개면 [연락처 추가]를 끄고(aria-disabled — 초점은 받는다) 아래에 까닭, 그룹 100개면 [새 그룹]도 같다
// - 지금 보는 목록의 줄은 aria-current="page" (연락처를 골라도 그 목록에 있으면)
// - 그룹 ⋯: 이름 바꾸기(창) · 그룹에 메일 쓰기(받는 사람을 채운 새 메일) · 그룹 지우기(확인 없이 — 알림의 [되돌리기])
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ContactsGroupRow from '@/components/contacts/ContactsGroupRow.vue'
import type { ContactGroupAction } from '@/components/contacts/contacts-ui'
import { useContactActions } from '@/composables/useContactActions'
import { useContactsDialogs } from '@/composables/useContactsDialogs'
import { CONTACTS_LIST_TEXT, CONTACTS_PANEL_TEXT, DIRECTORY_KIND_TEXT } from '@/shared/constants/contacts'
import type { ContactGroup, DirectoryKind } from '@/shared/types/contacts'
import {
contactsListLocation,
contactsRouteStateOf,
contactsSourceKey,
type ContactsListSource,
} from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
const route = useRoute()
const store = useContactsStore()
const dialogs = useContactsDialogs()
const actions = useContactActions()
const ALL: ContactsListSource = { kind: 'all' }
const DIRECTORY_KINDS: readonly DirectoryKind[] = ['person', 'shared']
const activeKey = computed(() => contactsSourceKey(contactsRouteStateOf(route)?.source ?? null))
const totalText = computed(() => (store.contactTotal === null ? '' : formatCount(store.contactTotal)))
const directoryLinks = computed(() =>
DIRECTORY_KINDS.map((kind) => {
const source: ContactsListSource = { kind: 'directory', directoryKind: kind }
const total = store.directoryStatus === 'ready' ? store.directoryTotals[kind] : null
return {
kind,
key: contactsSourceKey(source),
label: DIRECTORY_KIND_TEXT[kind],
icon: kind === 'person' ? ('user' as const) : ('mail' as const),
to: contactsListLocation(source),
count: total === null ? '' : formatCount(total),
}
}),
)
function onAdd() {
if (!store.isFull) dialogs.openContact(null)
}
function onNewGroup() {
if (!store.isGroupsFull) dialogs.openGroupName(null)
}
function onGroupAction(action: ContactGroupAction, group: ContactGroup) {
if (action === 'rename') dialogs.openGroupName(group)
else if (action === 'compose') void actions.composeToGroup(group)
else void actions.removeGroup(group)
}
</script>
<template>
<nav
class="contacts-panel"
:aria-label="CONTACTS_PANEL_TEXT.label"
data-contacts-panel
>
<div class="contacts-panel__add">
<button
type="button"
class="contacts-panel__primary"
:aria-disabled="store.isFull ? 'true' : undefined"
:aria-describedby="store.isFull ? 'contacts-full-note' : undefined"
data-contacts-add
@click="onAdd"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<p
v-if="store.isFull"
id="contacts-full-note"
class="contacts-panel__note"
>
{{ CONTACTS_PANEL_TEXT.fullNote }}
</p>
</div>
<div class="contacts-panel__section">
<p
id="contacts-panel-mine"
class="contacts-panel__heading"
>
{{ CONTACTS_PANEL_TEXT.mine }}
</p>
<ul
class="contacts-panel__list"
aria-labelledby="contacts-panel-mine"
>
<li>
<RouterLink
:to="contactsListLocation(ALL)"
:class="['contacts-panel__link', { 'is-active': activeKey === 'all' }]"
:aria-current="activeKey === 'all' ? 'page' : undefined"
data-contacts-source="all"
>
<AppIcon name="book" />
<span class="contacts-panel__name">{{ CONTACTS_PANEL_TEXT.all }}</span>
<span class="contacts-panel__count">{{ totalText }}</span>
</RouterLink>
</li>
</ul>
</div>
<div class="contacts-panel__section">
<p
id="contacts-panel-groups"
class="contacts-panel__heading"
>
{{ CONTACTS_PANEL_TEXT.groups }}
</p>
<ul
v-if="store.groups.length > 0"
class="contacts-panel__list"
aria-labelledby="contacts-panel-groups"
>
<ContactsGroupRow
v-for="group in store.groups"
:key="group.id"
:group="group"
:active="activeKey === `group:${group.id}`"
@action="onGroupAction"
/>
</ul>
<ul
v-else-if="store.groupsStatus === 'loading'"
class="contacts-panel__list"
:aria-label="CONTACTS_PANEL_TEXT.groupsLoading"
aria-busy="true"
>
<li
v-for="row in 2"
:key="row"
class="contacts-panel__placeholder"
/>
</ul>
<div
v-else-if="store.groupsStatus === 'error' && store.groupsError"
class="contacts-panel__error"
role="alert"
>
<p class="contacts-panel__error-text">
{{ store.groupsError.message }}
</p>
<button
type="button"
class="contacts-panel__retry"
@click="store.loadGroups()"
>
{{ CONTACTS_LIST_TEXT.retry }}
</button>
</div>
<button
type="button"
class="contacts-panel__new"
:aria-disabled="store.isGroupsFull ? 'true' : undefined"
:aria-describedby="store.isGroupsFull ? 'contacts-groups-full-note' : undefined"
data-contacts-new-group
@click="onNewGroup"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="1.9"
/>
<span>{{ CONTACTS_PANEL_TEXT.newGroup }}</span>
</button>
<p
v-if="store.isGroupsFull"
id="contacts-groups-full-note"
class="contacts-panel__note"
>
{{ CONTACTS_PANEL_TEXT.groupsFullNote }}
</p>
</div>
<div class="contacts-panel__section">
<p
id="contacts-panel-company"
class="contacts-panel__heading"
>
{{ CONTACTS_PANEL_TEXT.company }}
</p>
<ul
class="contacts-panel__list"
aria-labelledby="contacts-panel-company"
>
<li
v-for="link in directoryLinks"
:key="link.kind"
>
<RouterLink
:to="link.to"
:class="['contacts-panel__link', { 'is-active': activeKey === link.key }]"
:aria-current="activeKey === link.key ? 'page' : undefined"
:data-contacts-source="link.key"
>
<AppIcon :name="link.icon" />
<span class="contacts-panel__name">{{ link.label }}</span>
<span class="contacts-panel__count">{{ link.count }}</span>
</RouterLink>
</li>
</ul>
</div>
</nav>
</template>
<style scoped>
/* 메일함 패널과 같은 바탕 · 간격 (디자인 원본 Contacts: 208 · #F8F9FB · 14 10) */
.contacts-panel {
flex: 1 1 13rem;
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-14) var(--space-10);
border-right: var(--border-width) solid var(--color-border);
background: var(--color-surface-panel);
}
.contacts-panel__add {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* [연락처 추가] — 메일함 패널의 [메일 쓰기]와 같은 채운 단추 44 */
.contacts-panel__primary {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-8);
min-height: var(--size-touch);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.contacts-panel__primary:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 연락처 3,000개 — 회색으로 끈다 (디자인 원본 ContactsEdit "한도": #D0D5DD) */
.contacts-panel__primary[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
.contacts-panel__note {
margin: 0;
padding: 0 var(--space-2);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.contacts-panel__section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* 묶음 이름 — 12px 굵게 (디자인 원본 Contacts) */
.contacts-panel__heading {
margin: 0;
padding: 0 var(--space-10) var(--space-4);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.contacts-panel__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
/* 줄 40 — 메일함 패널의 줄과 같다 */
.contacts-panel__link {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.contacts-panel__link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.contacts-panel__link.is-active {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
.contacts-panel__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts-panel__count {
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* [새 그룹] 44 — 메일함 패널의 [새 메일함]과 같다 */
.contacts-panel__new {
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
text-align: left;
}
.contacts-panel__new:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.contacts-panel__new[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.contacts-panel__placeholder {
height: var(--size-row-min);
margin: var(--space-2) 0;
border-radius: var(--radius-item);
background: var(--color-border-subtle);
}
.contacts-panel__error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: 0 var(--space-10);
}
.contacts-panel__error-text {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
}
.contacts-panel__retry {
min-height: var(--size-touch);
padding: 0;
border: 0;
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.contacts-panel {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,206 @@
<script setup lang="ts">
// 상단 바의 찾기 칸 — 주소록 화면에서는 "주소록에서 찾기" (디자인 원본 Contacts 머리 · README "8단계 › 주소록 화면")
// - 지금 보는 목록 안에서 찾는다(전체 · 그룹 · 직원 · 공용 주소). 치는 대로 — 250ms 쉬면 주소의 ?q= 를 바꾸고(기록을 쌓지 않는다) 목록이 서버 q 로 다시 받는다
// - 한글 조합 중인 글자도 찾는다(받는 사람 칸처럼 input · compositionupdate 의 보이는 글). 초성만 친 글(ㅂㅈㅎ)은 서버가 초성으로 찾는다.
// 글자와 자모가 섞였으면(조합 중 — "박ㅈ") 보내지 않고 지난 결과를 둔다 (contacts.ts 의 contactSearchQueryOf)
// - Enter 는 기다리지 않고 바로 찾는다(조합을 끝내는 Enter 는 무시 — isImeComposing). Esc · ✕(40×38 — 밀도 예외)는 찾을 글을 지운다
// - 다른 목록으로 가면(패널) 그 주소의 찾을 글로 맞춘다 — 이 칸이 넣은 글이 돌아온 것이면(그사이 더 쳤을 수 있다) 칸을 덮지 않는다
import { computed, onScopeDispose, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { useClock, type ClockTimer } from '@/composables/useClock'
import { CONTACTS_SEARCH_DELAY_MS, CONTACTS_SEARCH_MAX_LENGTH, CONTACTS_SEARCH_TEXT } from '@/shared/constants/contacts'
import { contactSearchQueryOf } from '@/shared/utils/contacts'
import { contactsLocationWithQuery, contactsRouteStateOf, contactsSourceKey } from '@/shared/utils/contacts-route'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
const route = useRoute()
const router = useRouter()
const clock = useClock()
const state = computed(() => contactsRouteStateOf(route))
/** 칸에 보이는 글 — 한글 조합 중인 글자 포함 */
const text = ref<string>(state.value?.q ?? '')
let timer: ClockTimer | null = null
/** 이 칸이 마지막으로 주소에 넣은 찾을 글 */
let applied: string | null = state.value?.q ?? null
const placeholder = computed(() =>
state.value?.source.kind === 'directory'
? CONTACTS_SEARCH_TEXT.directoryPlaceholder
: CONTACTS_SEARCH_TEXT.placeholder,
)
function cancel() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
/** 칸의 글을 주소에 넣는다 — 같은 목록 · 같은 고른 항목에 찾을 글만 바꾼다 (replace) */
function apply() {
timer = null
const current = state.value
if (!current) return
const q = contactSearchQueryOf(text.value)
if (q === undefined || q === current.q) return
applied = q
void router.replace(contactsLocationWithQuery(current, q))
}
function onInput(event: Event) {
text.value = (event.target as HTMLInputElement).value
cancel()
timer = clock.setTimeout(apply, CONTACTS_SEARCH_DELAY_MS)
}
function clear() {
cancel()
text.value = ''
apply()
}
function onKeydown(event: KeyboardEvent) {
if (isImeComposing(event)) return
if (event.key === 'Enter') {
event.preventDefault()
cancel()
apply()
return
}
if (event.key === 'Escape' && text.value !== '') {
event.preventDefault()
clear()
}
}
// 다른 목록으로 가거나(패널 · 뒤로 가기) 주소의 찾을 글이 바뀌면 칸을 맞춘다 — 이 칸이 넣은 글이 돌아온 것이면 그대로 둔다
watch(
[() => contactsSourceKey(state.value?.source ?? null), () => state.value?.q ?? null],
([sourceKey, q], [previousKey]) => {
if (sourceKey === previousKey && q === applied) return
cancel()
applied = q
text.value = q ?? ''
},
)
onScopeDispose(cancel)
</script>
<template>
<form
role="search"
:class="['contacts-search', { 'is-active': Boolean(state?.q) }]"
@submit.prevent
>
<AppIcon
name="search"
class="contacts-search__icon"
/>
<input
type="search"
class="contacts-search__input"
:aria-label="CONTACTS_SEARCH_TEXT.label"
:placeholder="placeholder"
:value="text"
:maxlength="CONTACTS_SEARCH_MAX_LENGTH"
autocomplete="off"
enterkeyhint="search"
data-contacts-search
@input="onInput"
@compositionupdate="onInput"
@keydown="onKeydown"
>
<button
v-if="text !== ''"
type="button"
class="contacts-search__clear"
:aria-label="CONTACTS_SEARCH_TEXT.clear"
:title="CONTACTS_SEARCH_TEXT.clear"
@click="clear"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</form>
</template>
<style scoped>
/* 메일 검색 칸과 같은 모양 (MailSearchBox — 디자인 원본 Contacts 머리: 40 · 테두리 #8A94A3) */
.contacts-search {
flex: 1 1 17.5rem;
max-width: 32.5rem;
display: flex;
align-items: center;
gap: var(--space-8);
height: var(--size-search-box);
margin: 0;
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-surface);
}
/* 찾는 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 */
.contacts-search.is-active,
.contacts-search:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
.contacts-search:has(.contacts-search__clear) {
padding-right: 0;
}
.contacts-search__icon {
color: var(--color-text-muted);
}
.contacts-search__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
}
.contacts-search__input:focus,
.contacts-search__input:focus-visible {
outline: none;
}
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
.contacts-search__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css — 메일 검색 칸의 ✕ 와 같다) */
.contacts-search__clear {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-search-clear-width);
height: var(--size-search-clear-height);
padding: 0;
border: 0;
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
background: transparent;
color: var(--color-text-muted);
}
.contacts-search__clear:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.contacts-search__clear:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,32 @@
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog)
/** 그룹 줄 ⋯ 메뉴가 할 수 있는 일 */
export type ContactGroupAction = 'rename' | 'compose' | 'delete'
/** 줄 하나 — key 는 v-for 키 · 고정 id 의 끝 (줄을 빼도 다른 줄의 값 · 초점이 섞이지 않게) */
export interface ContactEntryRowView {
key: number
label: string
value: string
}
/** 줄 아래의 막지 않는 안내 — 같은 주소가 다른 연락처에 있다 (+ [그 연락처 보기]) */
export interface ContactEntryWarning {
text: string
/** [그 연락처 보기]가 갈 연락처 */
contactId: string
linkLabel: string
}
/** 줄을 고친 것 — 종류 · 값 가운데 바뀐 것만 */
export interface ContactEntryPatch {
label?: string
value?: string
}
/** 키 하나를 뺀 기록 — 같은 주소 안내를 지울 때 */
export function withoutKey<T>(record: Readonly<Record<number, T>>, key: number): Record<number, T> {
const next: Record<number, T> = { ...record }
delete next[key]
return next
}
@@ -2,7 +2,8 @@
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답 · 필터 고치기 창 위) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide) · 주소록의 창(8단계 — 연락처 추가 · 고치기 · 구성원 넣기 620 wide,
// 새 그룹 · 그룹 이름 바꾸기 400 medium)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// 브라우저가 막지 못하게 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면(나가기 확인의 "계속 고치기") reshow() 로 다시 띄운다 —
@@ -26,6 +27,8 @@ interface Props {
role?: 'alertdialog'
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
wide?: boolean
/** 칸 하나의 창 — 폭 400 · 좌우 여백 20 (디자인 원본 ContactsEdit 의 새 그룹). wide 가 앞선다 */
medium?: boolean
}
interface Emits {
@@ -39,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
describedBy: undefined,
role: undefined,
wide: false,
medium: false,
})
const emit = defineEmits<Emits>()
@@ -125,7 +129,7 @@ onMounted(() => {
<template>
<dialog
ref="dialogRef"
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
:class="['modal-dialog', { 'modal-dialog--wide': wide, 'modal-dialog--medium': medium && !wide }]"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@@ -170,6 +174,12 @@ onMounted(() => {
padding: var(--space-18) var(--space-20) var(--space-14);
}
/* 칸 하나의 창 — 새 그룹 · 그룹 이름 바꾸기 (디자인 원본 ContactsEdit: 폭 400 · 안쪽 여백 18 20 14) */
.modal-dialog--medium {
width: min(25rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-20) var(--space-14);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
@@ -0,0 +1,38 @@
<script setup lang="ts">
// 메일 화면의 연락처 추가 창 — 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」가 연 창(useMailContactDialog)을 그린다. 메일 레이아웃이 둔다.
// 메일이 바뀌어도(뒤로 · 이전/다음 · 바탕화면 알림) 창이 남는다 — 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다(ContactDialog 의 useDialogLeaveConfirm)
// 닫으면 초점을 연 ⋯ 단추로 돌려준다(그 단추가 사라졌으면 — 다른 메일로 바뀜 — 그대로 둔다).
// 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 닫는다 — 주소록 화면의 창 묶음과 같다
import { nextTick, watch } from 'vue'
import ContactDialog from '@/components/contacts/ContactDialog.vue'
import { useMailContactDialog } from '@/composables/useMailContactDialog'
import { useAuthStore } from '@/stores/auth.store'
const handle = useMailContactDialog()
const authStore = useAuthStore()
function onClose() {
const returnFocus = handle?.request.value?.returnFocus ?? null
handle?.close()
void nextTick(() => {
const active = document.activeElement
if (active === null || active === document.body || !active.isConnected) returnFocus?.()
})
}
watch(
() => authStore.generation,
() => handle?.close(),
)
</script>
<template>
<ContactDialog
v-if="handle?.request.value"
:open="true"
:contact="null"
:prefill="handle.request.value.prefill"
@close="onClose"
/>
</template>
@@ -2,6 +2,8 @@
// 본문 툴바 버튼 — 분할 보기 읽기 · 목록 보기 읽기가 같은 모양을 쓴다 (디자인 원본 Main · MainListRead)
// 안쪽 좌우 여백은 --toolbar-button-inline(10px) — 툴바 안쪽 여백(22px)과 더해 첫 아이콘이 콘텐츠 열 32px 선에 온다.
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
@@ -17,6 +19,12 @@ interface Props {
/** accent: 강조 글자(목록으로) · danger: 삭제 */
tone?: 'default' | 'accent' | 'danger'
disabled?: boolean
/** 누를 수 없지만 초점은 받는다 — aria-disabled (까닭은 describedby 로) */
unavailable?: boolean
/** 하는 중 — aria-busy, 누르면 아무것도 하지 않는다 */
busy?: boolean
/** 까닭 · 설명 글의 id — aria-describedby */
describedby?: string
iconStrokeWidth?: number
}
@@ -24,15 +32,23 @@ interface Emits {
(e: 'click', event: MouseEvent): void
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
to: null,
href: null,
tone: 'default',
disabled: false,
unavailable: false,
busy: false,
describedby: undefined,
iconStrokeWidth: 1.75,
})
const emit = defineEmits<Emits>()
function onButtonClick(event: MouseEvent) {
if (props.unavailable || props.busy) return
emit('click', event)
}
</script>
<template>
@@ -66,7 +82,10 @@ const emit = defineEmits<Emits>()
type="button"
:class="['tool-button', `tool-button--${tone}`]"
:disabled="disabled"
@click="emit('click', $event)"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
:aria-describedby="describedby"
@click="onButtonClick"
>
<AppIcon
:name="icon"
@@ -92,21 +111,26 @@ const emit = defineEmits<Emits>()
text-decoration: none;
}
.tool-button:hover:not(:disabled) {
.tool-button:hover:not(:disabled, [aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.tool-button:disabled {
.tool-button:disabled,
.tool-button[aria-disabled='true'] {
opacity: 0.5;
}
.tool-button[aria-busy='true'] {
cursor: progress;
}
.tool-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
}
.tool-button--accent:hover:not(:disabled) {
.tool-button--accent:hover:not(:disabled, [aria-disabled='true']) {
color: var(--color-brand-strong);
}
@@ -114,7 +138,7 @@ const emit = defineEmits<Emits>()
color: var(--color-danger);
}
.tool-button--danger:hover:not(:disabled) {
.tool-button--danger:hover:not(:disabled, [aria-disabled='true']) {
background: var(--color-danger-surface);
color: var(--color-danger);
}
@@ -6,6 +6,7 @@
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
// - 주소록 패널의 그룹 줄 ⋯ 도 이 단추를 쓴다(8단계 — 같은 32×40 밀도 예외) — 단추 · 메뉴 이름은 label · menuLabel 로 바꾼다
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -18,6 +19,10 @@ interface Props {
/** 메일함 이름 — 단추 · 메뉴의 이름 */
mailboxName: string
items: readonly EditorMenuItem[]
/** 단추 이름 — 주지 않으면 "{이름} 메일함 메뉴" (주소록 그룹 줄은 "‘거래처’ 그룹 메뉴") */
label?: string
/** 메뉴 이름 — 주지 않으면 "{이름} 메일함" */
menuLabel?: string
}
interface Emits {
@@ -25,7 +30,10 @@ interface Emits {
(e: 'open-change', open: boolean): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
label: undefined,
menuLabel: undefined,
})
const emit = defineEmits<Emits>()
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
@@ -40,6 +48,8 @@ const byKeyboard = ref<boolean>(false)
const upward = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
const buttonLabel = computed(() => props.label ?? MAILBOX_PANEL_TEXT.more(props.mailboxName))
const menuName = computed(() => props.menuLabel ?? MAILBOX_PANEL_TEXT.menu(props.mailboxName))
function setOpen(next: boolean) {
if (open.value === next) return
@@ -123,8 +133,8 @@ watch(
ref="buttonRef"
type="button"
class="mailbox-more__button"
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
:aria-label="buttonLabel"
:title="buttonLabel"
aria-haspopup="menu"
:aria-expanded="open"
data-mailbox-more
@@ -140,7 +150,7 @@ watch(
v-if="open"
ref="menuRef"
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
:label="menuName"
:items="items"
:trigger="trigger"
align="end"
@@ -1,64 +1,109 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 그 아래 둘째 묶음에 「보낸 사람을 주소록에 추가」(8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께") ·
// 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
// 주소록: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다
// (창은 메일 레이아웃에 있다 — useMailContactDialog. 메일을 바꿔도 창이 남는다).
// 있으면 「주소록에서 보기」 — 주소록의 그 연락처로. 판단은 메뉴를 열 때 알림 없이 하고(받아 둔 결과가 있으면 그것으로 두고 다시 본다),
// 결과가 오기 전에는 「추가」로 둔다 (저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보인다).
// 연락처가 3,000개면 「추가」를 끄고 까닭을 아래에 (수를 모르거나 3,000개로 받아 두었으면 열 때 다시 받아 본다)
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 보낸 사람 주소가 없는 메일에는 두 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { useMailContactDialog } from '@/composables/useMailContactDialog'
import { CONTACT_READER_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { Contact } from '@/shared/types/contacts'
import { contactLocation } from '@/shared/utils/contacts-route'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { useContactsStore } from '@/stores/contacts.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
interface Props {
messageId: string
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
/** 보낸 사람 주소 — 없으면 주소록 · 필터 항목을 두지 않는다 */
senderEmail?: string | null
/** 보낸 사람 표시 이름 — 주소록에 추가할 때 이름 칸을 채운다 */
senderName?: string | null
}
const props = withDefaults(defineProps<Props>(), {
senderEmail: null,
senderName: null,
})
const { downloadMessageSource } = useMail()
const filterStore = useMailFilterStore()
const contactsStore = useContactsStore()
const contactDialog = useMailContactDialog()
const router = useRouter()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 보낸 사람의 연락처 — 내 주소록에 있으면 그 연락처, 없거나 아직 모르면 null */
const senderContact = ref<Contact | null>(null)
let lookupSeq = 0
const sender = computed(() => props.senderEmail?.trim() ?? '')
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
if (sender.value) {
if (!sender.value) return list
list.push(menuSeparator('sender'))
if (senderContact.value) {
list.push({ id: 'contact-view', label: CONTACT_READER_TEXT.view, icon: 'book' })
} else {
list.push({
id: 'filter',
label: MAIL_READER_MORE_TEXT.createFilter,
icon: 'filter',
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
id: 'contact-add',
label: CONTACT_READER_TEXT.add,
icon: 'user-plus',
disabled: contactsStore.isFull,
note: contactsStore.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined,
})
}
list.push({
id: 'filter',
label: MAIL_READER_MORE_TEXT.createFilter,
icon: 'filter',
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
})
return list
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
/** 보낸 사람 주소가 내 주소록에 있는지 — 알림 없이. 모르게 되면(실패) 지난 결과를 둔다 */
async function lookUpSender() {
const email = sender.value
lookupSeq += 1
const seq = lookupSeq
const found = await contactsStore.findContactByEmail(email)
if (seq !== lookupSeq || sender.value !== email || found === undefined) return
senderContact.value = found
}
function openMenu(keyboard: boolean) {
byKeyboard.value = keyboard
open.value = true
if (!sender.value) return
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
if (sender.value) filterStore.ensureLoaded()
filterStore.ensureLoaded()
// 주소록에 있는지 · 연락처가 3,000개인지 (알리지 않는다)
void lookUpSender()
contactsStore.ensureContactTotal()
}
function onClick(event: MouseEvent) {
@@ -85,10 +130,29 @@ function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
if (id === 'contact-view' && senderContact.value) void router.push(contactLocation(senderContact.value.id))
if (id === 'contact-add' && sender.value && !contactsStore.isFull) {
contactDialog?.open({ name: props.senderName?.trim() || null, email: sender.value, label: 'work' }, focusButton)
}
if (id === 'filter' && sender.value && !filterStore.isFull) {
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
}
}
/** 연락처 창을 닫은 뒤 — 이 단추가 아직 있으면 초점을 돌려준다 (다른 메일로 바뀌어 단추가 사라졌으면 그대로) */
function focusButton() {
const button = buttonRef.value
if (button?.isConnected) button.focus()
}
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다
watch(
() => normalizeEmail(sender.value),
() => {
lookupSeq += 1
senderContact.value = null
},
)
</script>
<template>
@@ -146,7 +210,7 @@ function onSelect(id: string) {
color: var(--color-text);
}
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
/* 메뉴 폭 280 (디자인 원본 ContactsMail) — 필터 50개 · 연락처 3,000개의 까닭(note)은 그 안에서 줄을 바꾼다 */
.reader-more__menu {
min-width: 12.5rem;
max-width: 17.5rem;
@@ -1,11 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { defineComponent, h, provide } from 'vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Contact } from '@/shared/types/contacts'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -19,7 +24,7 @@ import {
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
const DETAIL: MessageDetail = {
@@ -50,6 +55,11 @@ const DETAIL: MessageDetail = {
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
/** 주소록 (8단계) — 읽기 화면 ⋯ 를 열면 보낸 사람이 주소록에 있는지 · 연락처 수를 알려고 받는다 */
function contactPage(items: Contact[], total = items.length): PaginatedResult<Contact> {
return { items, pageInfo: { hasNext: false, nextCursor: null, total } }
}
describe('MessageReader 도구 모음', () => {
let pinia: Pinia
@@ -497,6 +507,12 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
refresh?: MockResult
filters?: MailFilterSet
from?: MessageDetail['from']
/** GET /contacts?q=<보낸 사람> — 주소록에서 찾은 것 */
contacts?: () => MockResult | Promise<MockResult>
/** GET /contacts?limit=1 — 연락처 수 */
contactTotal?: number
/** 이동 가드가 걸리게 지금 경로를 준다 — 연락처 추가 창(나가기 확인)을 띄울 때 */
routeView?: boolean
} = {},
) {
const box = options.box ?? 'inbox'
@@ -504,6 +520,11 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
requests = installMockApi((request) => {
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
if (request.url === '/contacts' && request.params.q === undefined) {
return ok(contactPage([], options.contactTotal ?? 0))
}
if (request.url === '/contacts') return options.contacts?.() ?? ok(contactPage([]))
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url === '/messages' && request.method === 'GET') {
@@ -525,7 +546,17 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const router = createTestRouter()
await router.push(`/mail/${box}/m1`)
await router.isReady()
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
// 메일 레이아웃처럼 — 읽기 화면 ⋯ 의 연락처 추가 창은 레이아웃이 그린다 (useMailContactDialog)
const ReaderInLayout = defineComponent({
setup() {
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
return () => h('div', [h(MessageReader), h(MailContactDialogHost)])
},
})
const wrapper = mount(ReaderInLayout, {
global: { plugins: [pinia, router], provide: options.routeView ? routeViewProvide(router) : {} },
attachTo: document.body,
})
return { wrapper, router, store }
}
@@ -591,14 +622,29 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
expect(items.map((item) => item.text())).toEqual([
'원문 내려받기 (.eml)',
'보낸 사람을 주소록에 추가',
'이런 메일로 필터 만들기',
])
// 원문 아래가 둘째 묶음 (디자인 원본 ContactsMail)
expect(wrapper.findAll('[role="menu"] hr')).toHaveLength(1)
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await flushPromises()
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /mail-filters',
'GET /contacts',
'GET /contacts',
'GET /auth/me',
])
expect(requests.filter((request) => request.url === '/contacts').map((request) => request.params)).toEqual([
{ q: 'doyun.lee@arasystem.it.kr', limit: 100 },
{ limit: 1 },
])
expect(downloads).toEqual(['/api/messages/m1/source'])
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(more.element)
@@ -621,7 +667,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]')[1]
const item = wrapper.findAll('[role="menuitem"]')[2]
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
expect(item?.attributes('disabled')).toBeUndefined()
@@ -647,7 +693,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menuitem"]')
const item = items[1]
const item = items[2]
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.attributes('disabled')).toBeUndefined()
@@ -656,6 +702,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
await items[1]?.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement).toBe(item?.element)
await item?.trigger('click')
@@ -673,7 +720,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
},
})
const more = wrapper.get('[data-reader-more]')
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
const filterItem = () => wrapper.findAll('[role="menuitem"]')[2]
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
await more.trigger('click')
@@ -696,13 +743,135 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(useToastStore().toasts).toEqual([])
})
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
it('보낸 사람 주소가 없는 메일에는 주소록 · 필터 항목을 두지 않는다 (필터 · 주소록도 받지 않는다)', async () => {
const { wrapper } = await mountWith({ from: null })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(wrapper.find('[role="menu"] hr').exists()).toBe(false)
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
expect(requests.some((request) => request.url === '/contacts')).toBe(false)
})
describe('보낸 사람을 주소록에 (8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께")', () => {
const DOYUN_CONTACT: Contact = {
id: 'c7',
name: '이도윤',
emails: [{ address: 'Doyun.Lee@arasystem.it.kr', label: 'work' }],
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: [],
}
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[1]
it('내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 보낸 사람의 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다', async () => {
const { wrapper, router } = await mountWith({ routeView: true })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(contactItem(wrapper)?.text()).toBe('보낸 사람을 주소록에 추가')
await contactItem(wrapper)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(wrapper.get('#contact-dialog').attributes('open')).toBeDefined()
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가')
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
const email = wrapper.get('[id^="contact-email-"]').element as HTMLInputElement
expect(email.value).toBe('doyun.lee@arasystem.it.kr')
// 채운 그대로 [취소] — 바꾼 것이 없으니 묻지 않고 닫고, 초점은 ⋯ 로 돌아온다
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
expect(wrapper.find('#contact-leave-dialog[open]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-reader-more]').element)
})
it('메일을 바꿔도(이전/다음 · 뒤로 · 바탕화면 알림 · 다시 받는 동안) 연락처 추가 창은 남는다 — 창은 메일 레이아웃에 있다', async () => {
const { wrapper, router, store } = await mountWith({ routeView: true })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await contactItem(wrapper)?.trigger('click')
await flushPromises()
await wrapper.get('#contact-title').setValue('수석')
// 다음 메일을 받는 동안(선택이 비었다) — 읽기 화면의 도구 줄 · ⋯ 가 사라진다
store.selected = null
store.selectedId = 'm2'
store.selectedStatus = 'loading'
await router.push('/mail/inbox/m2')
await flushPromises()
expect(wrapper.find('[data-reader-more]').exists()).toBe(false)
expect(wrapper.get('#contact-dialog').attributes('open')).toBeDefined()
expect((wrapper.get('#contact-title').element as HTMLInputElement).value).toBe('수석')
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
store.selected = { ...DETAIL, id: 'm2', subject: '다음 메일', from: { name: '박민수', email: 'minsu.park@arasystem.it.kr' } }
store.selectedStatus = 'ready'
await flushPromises()
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
expect(wrapper.find('#contact-leave-dialog[open]').exists()).toBe(false)
// 세션이 끝나면 닫는다 (주소록 화면의 창 묶음과 같다)
useAuthStore().expireSession()
await flushPromises()
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
})
it('내 주소록에 있으면(대소문자 무시) 「주소록에서 보기」 — 그 연락처를 고른 주소록으로 간다', async () => {
const { wrapper, router } = await mountWith({ contacts: () => ok(contactPage([DOYUN_CONTACT])) })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(contactItem(wrapper)?.text()).toBe('주소록에서 보기')
await contactItem(wrapper)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('contacts-contact')
expect(router.currentRoute.value.params).toEqual({ contactId: 'c7' })
expect(useToastStore().toasts).toEqual([])
})
it('결과가 오기 전에는 「보낸 사람을 주소록에 추가」로 둔다 — 오면 바꾼다 · 못 받으면 그대로 (알리지 않는다)', async () => {
let finish: (result: MockResult) => void = () => undefined
const late = new Promise<MockResult>((resolve) => {
finish = resolve
})
const { wrapper } = await mountWith({ contacts: () => late })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(contactItem(wrapper)?.text()).toBe('보낸 사람을 주소록에 추가')
finish(ok(contactPage([DOYUN_CONTACT])))
await flushPromises()
expect(contactItem(wrapper)?.text()).toBe('주소록에서 보기')
const failing = await mountWith({ contacts: () => fail(503, 'MAIL_001') })
await failing.wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(failing.wrapper.findAll('[role="menuitem"]')[1]?.text()).toBe('보낸 사람을 주소록에 추가')
expect(useToastStore().toasts).toEqual([])
})
it('연락처가 3,000개면 「보낸 사람을 주소록에 추가」를 끄고 이름 아래에 까닭 — 골라도 창을 열지 않는다', async () => {
const { wrapper } = await mountWith({ contactTotal: 3000 })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = contactItem(wrapper)
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.get('.menu__note').text()).toBe(
'연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요.',
)
await item?.trigger('click')
await flushPromises()
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
})
})
})
@@ -3,7 +3,7 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기)
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
@@ -114,6 +114,7 @@ function leaveToList() {
<MessageMoreMenu
:message-id="message.id"
:sender-email="message.from?.email ?? null"
:sender-name="message.from?.name ?? null"
/>
</template>
</div>
@@ -3,39 +3,56 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
import type { ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
import type { GroupRecipients } from '@/shared/utils/contacts'
import { normalizeEmail, tokenFromAddress, type RecipientToken } from '@/shared/utils/mail-address'
import {
arrangeRecipientOptions,
filterRecipientSuggestions,
matchContactGroups,
} from '@/shared/utils/recipient-match'
import { flushPromises } from '@/test-utils/mock-api'
import RecipientField from './RecipientField.vue'
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
const models = new WeakMap<VueWrapper, RecipientToken[]>()
/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
function suggestionsOf(pool: RecipientSuggestion[]) {
/**
* 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰 + others — 다른 칸에 넣은 주소)는 뺀다.
* 그룹(8단계)은 groups 로 받아 두고, 구성원은 expansions 에서 꺼낸다(없으면 받지 못한 것 — null)
*/
function suggestionsOf(pool: RecipientSuggestion[], groupList: ContactGroup[] = []) {
const items = shallowRef(pool)
const groups = shallowRef(groupList)
const searches: string[] = []
const preloads: number[] = []
const expansions = new Map<string, () => Promise<GroupRecipients | null>>()
const others = new Set<string>()
let current: () => RecipientToken[] = () => []
const excluded = () =>
new Set([...others, ...current().map((token) => normalizeEmail(token.address.email))])
const handle: RecipientSuggestionsHandle = {
preload: () => {
preloads.push(preloads.length + 1)
},
matchesFor: (query) =>
filterRecipientSuggestions(
query,
items.value,
new Set(current().map((token) => normalizeEmail(token.address.email))),
),
matchesFor: (query) => filterRecipientSuggestions(query, items.value, excluded()),
optionsFor: (query) =>
arrangeRecipientOptions(handle.matchesFor(query), matchContactGroups(query, groups.value)),
search: (query) => {
searches.push(query)
},
expandGroup: (group) => expansions.get(group.id)?.() ?? Promise.resolve(null),
isAdded: (email) => excluded().has(normalizeEmail(email)),
}
return {
handle,
items,
groups,
searches,
preloads,
expansions,
others,
bind: (tokens: () => RecipientToken[]) => {
current = tokens
},
@@ -389,4 +406,223 @@ describe('RecipientField', () => {
wrapper.unmount()
})
})
describe('주소록 · 그룹 (8단계 — 디자인 원본 ContactsMail)', () => {
const JIHOON_WORK: RecipientSuggestion = {
name: '박지훈',
email: 'jihoon.park@hanbit.example',
source: 'contact',
company: '(주)한빛상사',
emailLabel: 'work',
}
const JIHOON_PERSONAL: RecipientSuggestion = {
name: '박지훈',
email: 'jihoon.p@mail.example',
source: 'contact',
company: '(주)한빛상사',
emailLabel: 'personal',
}
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
const SOYOUNG: RecipientSuggestion = { name: '박소영', email: 'soyoung.park@print.example', source: 'recent' }
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 4 }
const EMPTY_GROUP: ContactGroup = { id: 'g2', name: '거래 준비', memberCount: 0 }
async function typed(wrapper: VueWrapper, text: string) {
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue(text)
return input
}
function optionTexts(wrapper: VueWrapper): string[] {
return wrapper.findAll('[role="option"]').map((option) =>
option
.findAll('.recipient-suggestions__name, .recipient-suggestions__email')
.map((part) => part.text())
.join(' '),
)
}
const status = (wrapper: VueWrapper) => wrapper.get('#compose-to-group-status').text()
function deferred() {
let finish: (value: GroupRecipients | null) => void = () => undefined
const promise = new Promise<GroupRecipients | null>((resolve) => {
finish = resolve
})
return { promise, finish }
}
it('묶음은 주소록 → 그룹 → 사내 → 최근 — 주소록은 주소마다 한 줄, 회사 주소는 "주소 · 회사" · 그 밖은 "주소 · 개인"', async () => {
const wrapper = mountField([], suggestionsOf([SOYOUNG, JUNHO, JIHOON_WORK, JIHOON_PERSONAL], [VENDORS]))
await typed(wrapper, '박')
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
'주소록',
'사내',
'최근 주고받은 주소',
])
expect(optionTexts(wrapper)).toEqual([
'박지훈 jihoon.park@hanbit.example · (주)한빛상사',
'박지훈 jihoon.p@mail.example · 개인',
'박준호 junho.park@arasystem.it.kr',
'박소영 soyoung.park@print.example',
])
// 화살표 차례도 묶음 차례 그대로 — 처음 골라 둔 것은 주소록의 첫 줄
expect(wrapper.get('[role="option"][aria-selected="true"]').attributes('id')).toBe('compose-to-option-0')
wrapper.unmount()
})
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
const wrapper = mountField([], suggestionsOf([JUNHO], [VENDORS, EMPTY_GROUP]))
await typed(wrapper, 'ㄱㄹ')
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual(['그룹'])
const option = wrapper.get('[role="option"]')
expect(option.attributes('data-suggestion-group')).toBeDefined()
expect(option.find('svg').exists()).toBe(true)
expect(option.get('.recipient-suggestions__name strong').text()).toBe('거래')
expect(option.get('.recipient-suggestions__email').text()).toBe('4명 · 고르면 한꺼번에 넣습니다')
wrapper.unmount()
})
it('그룹을 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래에 알린다 — 이메일이 없는 구성원 · 이미 넣은 주소(다른 칸 포함)는 뺀다', async () => {
const suggestions = suggestionsOf([], [VENDORS])
const reply = deferred()
suggestions.expansions.set('g1', () => reply.promise)
suggestions.others.add('minho.jung@hanbit.example')
const seoyun = tokenFromAddress({ name: '강서윤', email: 'seoyun.kang@hanbit.example' })
const wrapper = mountField([seoyun], suggestions)
const input = await typed(wrapper, '거래')
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
input.element.dispatchEvent(enter)
await wrapper.vm.$nextTick()
expect(enter.defaultPrevented).toBe(true)
expect((input.element as HTMLInputElement).value).toBe('')
expect(status(wrapper)).toBe('‘거래처’ 그룹의 구성원을 불러오는 중…')
reply.finish({
addresses: [
{ name: '강서윤', email: 'seoyun.kang@hanbit.example' },
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: '정민호', email: 'minho.jung@hanbit.example' },
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
],
withoutEmail: 1,
})
await flushPromises()
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
'강서윤 <seoyun.kang@hanbit.example>',
'박지훈 <jihoon.park@hanbit.example>',
'최유진 <yujin.choi@greencloud.example>',
])
expect(status(wrapper)).toBe(
'‘거래처’ 그룹의 2명을 받는 사람에 넣었습니다. 주소가 없는 1명 · 이미 넣은 2명은 뺐습니다.',
)
expect(wrapper.get('#compose-to-group-status').attributes('role')).toBe('status')
// 다시 치기 시작하면 칸 아래 글을 지운다
await input.setValue('박')
expect(status(wrapper)).toBe('')
wrapper.unmount()
})
it('구성원을 받지 못하면 칸 아래 글을 지운다 (알림은 인터셉터) · 넣을 주소가 없으면 그렇게 알린다', async () => {
const suggestions = suggestionsOf([], [VENDORS])
const wrapper = mountField([], suggestions)
let input = await typed(wrapper, '거래처')
await wrapper.get('[role="option"]').trigger('click')
await flushPromises()
expect(status(wrapper)).toBe('')
expect(tokensOf(wrapper)).toEqual([])
suggestions.expansions.set('g1', () => Promise.resolve({ addresses: [], withoutEmail: 4 }))
input = await typed(wrapper, '거래처')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(status(wrapper)).toBe('‘거래처’ 그룹에서 받는 사람에 더 넣을 주소가 없습니다. 주소가 없는 4명은 뺐습니다.')
wrapper.unmount()
})
it('그룹을 이어 고르면 그룹마다 따로 기다려 끝나는 대로 넣는다 — 앞 그룹을 버리지 않는다 · 칸 아래 글은 받는 중인 마지막 그룹, 다 받으면 마지막에 끝난 그룹', async () => {
const ALPHA: ContactGroup = { id: 'g3', name: '프로젝트 알파', memberCount: 2 }
const suggestions = suggestionsOf([], [VENDORS, ALPHA])
const vendors = deferred()
const alpha = deferred()
suggestions.expansions.set('g1', () => vendors.promise)
suggestions.expansions.set('g3', () => alpha.promise)
const wrapper = mountField([], suggestions)
let input = await typed(wrapper, '거래처')
await input.trigger('keydown', { key: 'Enter' })
input = await typed(wrapper, '프로젝트')
await input.trigger('keydown', { key: 'Enter' })
expect(status(wrapper)).toBe('‘프로젝트 알파’ 그룹의 구성원을 불러오는 중…')
// 나중에 고른 그룹이 먼저 끝났다 — 넣고, 아직 받는 앞 그룹의 글을 둔다
alpha.finish({ addresses: [{ name: '최유진', email: 'yujin.choi@greencloud.example' }], withoutEmail: 0 })
await flushPromises()
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최유진 <yujin.choi@greencloud.example>'])
expect(status(wrapper)).toBe('‘거래처’ 그룹의 구성원을 불러오는 중…')
vendors.finish({
addresses: [
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
],
withoutEmail: 0,
})
await flushPromises()
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
'최유진 <yujin.choi@greencloud.example>',
'박지훈 <jihoon.park@hanbit.example>',
])
expect(status(wrapper)).toBe('‘거래처’ 그룹의 1명을 받는 사람에 넣었습니다. 이미 넣은 1명은 뺐습니다.')
wrapper.unmount()
})
it('두 그룹이 같은 틱에 끝나도 앞 그룹의 칩을 잃지 않는다 (아직 돌아오지 않은 값 위에 넣는다)', async () => {
const ALPHA: ContactGroup = { id: 'g3', name: '프로젝트 알파', memberCount: 1 }
const suggestions = suggestionsOf([], [VENDORS, ALPHA])
const vendors = deferred()
const alpha = deferred()
suggestions.expansions.set('g1', () => vendors.promise)
suggestions.expansions.set('g3', () => alpha.promise)
const wrapper = mountField([], suggestions)
let input = await typed(wrapper, '거래처')
await input.trigger('keydown', { key: 'Enter' })
input = await typed(wrapper, '프로젝트')
await input.trigger('keydown', { key: 'Enter' })
vendors.finish({ addresses: [{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], withoutEmail: 0 })
alpha.finish({ addresses: [{ name: '최유진', email: 'yujin.choi@greencloud.example' }], withoutEmail: 0 })
await flushPromises()
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
'박지훈 <jihoon.park@hanbit.example>',
'최유진 <yujin.choi@greencloud.example>',
])
wrapper.unmount()
})
it('구성원을 기다리는 사이 쓰기 화면이 양식을 새로 채우면(clearDraft) 늦게 온 구성원은 넣지 않는다', async () => {
const suggestions = suggestionsOf([], [VENDORS])
const reply = deferred()
suggestions.expansions.set('g1', () => reply.promise)
const wrapper = mountField([], suggestions)
const input = await typed(wrapper, '거래')
await input.trigger('keydown', { key: 'Enter' })
;(wrapper.vm as unknown as { clearDraft: () => void }).clearDraft()
reply.finish({ addresses: [{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], withoutEmail: 0 })
await flushPromises()
expect(tokensOf(wrapper)).toEqual([])
expect(status(wrapper)).toBe('')
wrapper.unmount()
})
})
})
+203 -80
View File
@@ -3,8 +3,12 @@
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 서버가 받는 사람을 거절하면(MAIL_002 · MAIL_010 — rejected) 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 두고 같은 안내 띠를 가리킨다
// (어느 주소인지는 모른다 — 밑줄은 긋지 않는다).
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
// 자동 완성 (디자인 원본 ComposeAssist · ContactsMail · README "2단계 › 받는 사람 자동 완성" · "8단계 › 메일과 함께") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "주소록" · "그룹" · "사내" · "최근 주고받은 주소" 묶음, 맞는 글자는 굵게
// - 그룹 줄을 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래에 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다." — 이메일이 없는 구성원 ·
// 이미 넣은 주소(받는 사람 · 참조 · 숨은참조 모두)는 빼고 뺀 것을 덧붙인다. 구성원을 받는 동안은 "…불러오는 중…". 다시 치면 그 글을 지운다.
// 그룹을 이어 고르면 그룹마다 따로 기다려 끝나는 대로 넣는다(앞 그룹을 버리지 않는다). 칸 아래 글은 받는 중인 마지막 그룹, 다 받았으면 마지막에 끝난 그룹의 것.
// 양식을 새로 채우면(clearDraft) 기다리던 그룹을 모두 버린다
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
// 친 글자가 완전한 메일 주소면 고르지 않는다: Enter 는 친 주소를 그대로 넣고, 화살표로 고른 뒤에만 추천이 들어간다.
// Shift+Tab 은 추천을 넣지 않는다 (원래대로 앞 칸으로 — 칸을 벗어나며 친 글자를 확정한다)
@@ -14,7 +18,7 @@
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, inject, ref, watch } from 'vue'
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import RecipientSuggestionList from '@/components/mail/RecipientSuggestionList.vue'
@@ -24,6 +28,8 @@ import {
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { RECIPIENT_SUGGESTIONS } from '@/composables/useRecipientSuggestions'
import { RECIPIENT_CONTACT_TEXT } from '@/shared/constants/contacts'
import { groupInsertedText } from '@/shared/utils/contacts'
import {
hasOpenRecipientGroup,
isValidEmail,
@@ -35,7 +41,7 @@ import {
type RecipientToken,
} from '@/shared/utils/mail-address'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { RecipientMatch } from '@/shared/utils/recipient-match'
import type { RecipientGroupMatch, RecipientOption } from '@/shared/utils/recipient-match'
interface Props {
modelValue: RecipientToken[]
@@ -79,6 +85,21 @@ const focused = ref<boolean>(false)
const dismissed = ref<boolean>(false)
/** 고른 추천 — -1 이면 고르지 않았다 (Enter 는 친 글자를 넣는다) */
const activeIndex = ref<number>(-1)
/** 그룹을 넣은 뒤(또는 구성원을 받는 동안) 칸 아래 글 — 다시 치면 지운다 */
const groupStatus = ref<string>('')
/** 양식 차례 — 양식을 새로 채우면(clearDraft) 올라 기다리던 그룹 넣기를 모두 버린다 (그룹을 고르는 것으로는 오르지 않는다) */
let draftEpoch = 0
/** 구성원을 받는 중인 그룹 — 고른 차례대로 (번호 → 그룹 이름) */
const pendingGroups = new Map<number, string>()
let groupPickCount = 0
/** 마지막에 끝난 그룹 넣기의 글 — 받는 중인 그룹이 없을 때 칸 아래에 보인다 */
let lastGroupResult = ''
/**
* 내보냈지만 아직 v-model 로 돌아오지 않은 값 — 두 그룹이 같은 틱에 끝나도 앞 그룹의 칩을 잃지 않게
* (부모가 값을 바꿔 다시 그리기 전에는 props.modelValue 가 옛 값이다)
*/
let emittedTokens: RecipientToken[] | null = null
let unmounted = false
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 잘못된 입력 — 형식이 틀린 주소가 있거나, 서버가 거절한 받는 사람이 이 칸에 들었을 수 있다 (rejected · 주소가 있는 칸) */
@@ -88,10 +109,11 @@ const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim()
const listId = computed(() => suggestionListId(props.inputId))
const hintId = computed(() => suggestionHintId(props.inputId))
const matches = computed<RecipientMatch[]>(() => (suggestions ? suggestions.matchesFor(query.value) : []))
const statusId = computed(() => `${props.inputId}-group-status`)
const options = computed<RecipientOption[]>(() => (suggestions ? suggestions.optionsFor(query.value) : []))
const listOpen = computed(
() =>
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && matches.value.length > 0,
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && options.value.length > 0,
)
/** 친 글자가 이미 완전한 메일 주소다 — 추천을 미리 고르지 않는다 */
const typedFullAddress = computed(() => isValidEmail(query.value))
@@ -110,21 +132,76 @@ function commit(text: string = draft.value) {
const tokens = splitRecipientInput(text)
.map(parseRecipient)
.filter((token): token is RecipientToken => token !== null)
clearDraft()
clearInput()
if (tokens.length === 0) return
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
}
/** 추천을 넣는다 — "이름 <주소>" 토큰으로. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
function pick(match: RecipientMatch) {
const { name, email } = match.suggestion
clearDraft()
/** 칸 아래 글 — 받는 중인 그룹이 있으면 마지막으로 고른 그룹의 "불러오는 중", 없으면 마지막에 끝난 그룹의 결과 */
function showGroupStatus() {
const loading = [...pendingGroups.values()].at(-1)
groupStatus.value = loading ? RECIPIENT_CONTACT_TEXT.groupLoading(loading) : lastGroupResult
}
/** 지금 칸의 값 — 내보낸 값이 아직 돌아오지 않았으면 그것 */
function currentTokens(): RecipientToken[] {
return emittedTokens ?? props.modelValue
}
/** 그룹의 칩을 넣는다 — 다음 그리기가 끝나면(nextTick) props 가 따라잡는다 */
function emitTokens(tokens: RecipientToken[]) {
emittedTokens = tokens
emit('update:modelValue', tokens)
void nextTick(() => {
emittedTokens = null
})
}
/**
* 그룹을 넣는다 — 구성원을 받아 대표 주소를 칩으로. 이메일이 없는 구성원 · 이미 넣은 주소(세 칸 모두)는 빼고 칸 아래에 알린다.
* 받지 못하면(인터셉터가 알렸다) 그 그룹의 글을 지운다. 그룹마다 따로 기다려 끝나는 대로 넣는다 — 양식을 새로 채웠으면(clearDraft) 결과를 버린다
*/
async function pickGroup(match: RecipientGroupMatch) {
if (!suggestions) return
const { group } = match
clearInput()
const epoch = draftEpoch
groupPickCount += 1
const pick = groupPickCount
pendingGroups.set(pick, group.name)
showGroupStatus()
const result = await suggestions.expandGroup(group)
if (epoch !== draftEpoch || unmounted) return
pendingGroups.delete(pick)
if (!result) {
lastGroupResult = ''
showGroupStatus()
return
}
const fresh = result.addresses.filter((address) => !suggestions.isAdded(address.email))
if (fresh.length > 0) emitTokens(mergeRecipientTokens(currentTokens(), fresh.map(tokenFromAddress)))
lastGroupResult = groupInsertedText(group.name, props.label, {
added: fresh.length,
withoutEmail: result.withoutEmail,
already: result.addresses.length - fresh.length,
})
showGroupStatus()
}
/** 추천을 넣는다 — 주소는 "이름 <주소>" 토큰으로, 그룹은 구성원 모두. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
function pick(option: RecipientOption) {
if (option.kind === 'group') {
void pickGroup(option.match)
return
}
const { name, email } = option.match.suggestion
clearInput()
emit('update:modelValue', mergeRecipientTokens(props.modelValue, [tokenFromAddress({ name, email })]))
}
/** 화살표로 고른다 — 끝에서 처음으로 돈다 */
function move(step: 1 | -1) {
const count = matches.value.length
const count = options.value.length
if (count === 0) return
if (activeIndex.value < 0) {
activeIndex.value = step === 1 ? 0 : count - 1
@@ -146,7 +223,7 @@ function onListKeydown(event: KeyboardEvent): boolean {
dismissed.value = true
return true
}
const active = matches.value[activeIndex.value]
const active = options.value[activeIndex.value]
// Shift+Tab 은 넣지 않는다 — 원래대로 앞 칸으로 간다
if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) && active) {
// Enter 가 폼을 제출(메일 발송)하지 않고, Tab 이 칸을 벗어나지 않게 — 넣은 뒤 이어 친다
@@ -212,108 +289,142 @@ function focus() {
inputRef.value?.focus()
}
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤, 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤 */
function clearInput() {
draft.value = ''
query.value = ''
}
/** 쓰기 화면이 양식을 새로 채울 때 — 쓰다 만 입력 · 그룹 넣기(기다리는 것 포함) · 칸 아래 글을 버린다 */
function clearDraft() {
clearInput()
draftEpoch += 1
pendingGroups.clear()
lastGroupResult = ''
groupStatus.value = ''
}
// v-model 이 바뀌면(조합이 끝남 · 확정 · 비움) 검색어도 그 값으로
watch(draft, (text) => {
query.value = text
})
// 친 글자가 바뀌면 — 닫았던 목록을 다시 열고, 첫 추천을 골라 둔다(완전한 주소면 고르지 않는다). 모자라면 서버에 묻는다
// 다시 치기 시작하면 그룹을 넣었다는 칸 아래 글을 지운다 (넣은 뒤 입력칸을 비운 것으로는 지우지 않는다)
watch(query, (text) => {
dismissed.value = false
activeIndex.value = isValidEmail(text) ? -1 : 0
if (text.trim() !== '') {
lastGroupResult = ''
groupStatus.value = ''
}
suggestions?.search(text)
})
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => matches.value.length,
() => options.value.length,
(count) => {
if (activeIndex.value >= count) activeIndex.value = count - 1
if (activeIndex.value < 0 && count > 0 && !typedFullAddress.value) activeIndex.value = 0
},
)
onBeforeUnmount(() => {
unmounted = true
})
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
<template>
<div class="recipient-field compose-field">
<label
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
>
<span class="recipient-chip__label">{{ token.label }}</span>
<div class="recipient-field-wrap">
<div class="recipient-field compose-field">
<label
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<span
v-if="!token.valid"
class="visually-hidden"
>(주소 형식 오류)</span>
<button
type="button"
class="recipient-chip__remove"
:aria-label="`${token.label} 삭제`"
:disabled="disabled"
@click="remove(token)"
v-for="token in modelValue"
:key="recipientTokenKey(token)"
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
>
<AppIcon
name="x"
:size="12"
:stroke-width="2.25"
/>
</button>
</span>
<input
:id="inputId"
ref="inputRef"
v-model="draft"
type="text"
inputmode="email"
autocomplete="off"
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
:role="suggestions ? 'combobox' : undefined"
:aria-autocomplete="suggestions ? 'list' : undefined"
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="invalid"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@keydown="onKeydown"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
>
</div>
<slot name="trailing" />
<span class="recipient-chip__label">{{ token.label }}</span>
<span
v-if="!token.valid"
class="visually-hidden"
>(주소 형식 오류)</span>
<button
type="button"
class="recipient-chip__remove"
:aria-label="`${token.label} 삭제`"
:disabled="disabled"
@click="remove(token)"
>
<AppIcon
name="x"
:size="12"
:stroke-width="2.25"
/>
</button>
</span>
<input
:id="inputId"
ref="inputRef"
v-model="draft"
type="text"
inputmode="email"
autocomplete="off"
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
:role="suggestions ? 'combobox' : undefined"
:aria-autocomplete="suggestions ? 'list' : undefined"
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="invalid"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@keydown="onKeydown"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
>
</div>
<slot name="trailing" />
<RecipientSuggestionList
v-if="listOpen"
:input-id="inputId"
:label="label"
:matches="matches"
:active-index="activeIndex"
:hint="hint"
@activate="activeIndex = $event"
@pick="pick"
/>
<RecipientSuggestionList
v-if="listOpen"
:input-id="inputId"
:label="label"
:options="options"
:active-index="activeIndex"
:hint="hint"
@activate="activeIndex = $event"
@pick="pick"
/>
</div>
<!-- 그룹을 넣은 결과 — 칸 아래(선 밖)에. 읽어 주는 곳이라 비어 있어도 둔다 (디자인 원본 ContactsMail "고른 뒤") -->
<p
v-if="suggestions"
:id="statusId"
class="recipient-field__status"
role="status"
>
{{ groupStatus }}
</p>
</div>
</template>
<style scoped>
.recipient-field-wrap {
display: flex;
flex-direction: column;
}
.recipient-field {
position: relative;
display: flex;
@@ -353,6 +464,18 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
/* 포커스는 행 전체의 아래 선으로 보인다 — styles/base.css 의 .compose-field 규칙 */
/* 그룹을 넣은 결과 — 칸의 32px 선에서 12.5px (디자인 원본 ContactsMail). 비었을 때는 자리를 차지하지 않는다 */
.recipient-field__status {
margin: 0;
padding: 0 var(--content-toolbar-start) 0 var(--content-inline);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.recipient-field__status:not(:empty) {
padding-block: var(--space-6);
}
.recipient-chip {
display: inline-flex;
align-items: center;
@@ -1,24 +1,36 @@
<script setup lang="ts">
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — RecipientField 가 칸 바로 아래에 연다
// - "사내" · "최근 주고받은 주소" 두 묶음, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · ContactsMail · README "2단계 › 받는 사람 자동 완성" · "8단계 › 메일과 함께") — RecipientField 가 칸 바로 아래에 연다
// - 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
// 주소록 줄은 주소 뒤에 설명 — 회사 주소는 회사 이름, 그 밖은 종류("개인") (contacts.ts 의 contactSuggestionDetail)
// 그룹 줄은 아이콘 + 그룹 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다"
// - 고르기 · 넣기 키는 입력칸이 받는다(초점은 늘 입력칸) — 이 목록은 그리기와 마우스(올리면 고르기 · 누르면 넣기)만 한다.
// 누르는 동안 입력칸의 초점을 지킨다(mousedown)
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import {
suggestionHintId,
suggestionListId,
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { highlightParts, type HighlightPart, type RecipientMatch } from '@/shared/utils/recipient-match'
import { RECIPIENT_CONTACT_TEXT } from '@/shared/constants/contacts'
import { contactSuggestionDetail } from '@/shared/utils/contacts'
import { formatCount } from '@/shared/utils/format'
import {
highlightParts,
recipientOptionGroup,
type HighlightPart,
type RecipientOption,
type RecipientOptionGroup,
} from '@/shared/utils/recipient-match'
interface Props {
/** 입력칸 id — 목록 · 추천 · 안내 id 의 앞부분 */
inputId: string
/** 칸 이름 — 목록의 이름("받는 사람 추천") */
label: string
matches: RecipientMatch[]
options: RecipientOption[]
/** 고른 추천 — -1 이면 고르지 않았다 */
activeIndex: number
/** 목록 아래 안내 */
@@ -27,7 +39,7 @@ interface Props {
interface Emits {
/** 누른 추천을 넣는다 */
(e: 'pick', match: RecipientMatch): void
(e: 'pick', option: RecipientOption): void
/** 마우스를 올린 줄을 고른다 */
(e: 'activate', index: number): void
}
@@ -35,40 +47,74 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 추천 묶음 이름 (디자인 원본 ComposeAssist) */
const GROUP_LABELS = { directory: '사내', recent: '최근 주고받은 주소' } as const
/** 추천 묶음 — 차례와 이름 (디자인 원본 ContactsMail) */
const GROUP_ORDER: readonly RecipientOptionGroup[] = ['contact', 'group', 'directory', 'recent']
const GROUP_LABELS: Readonly<Record<RecipientOptionGroup, string>> = {
contact: '주소록',
group: '그룹',
directory: '사내',
recent: '최근 주고받은 주소',
}
interface OptionView {
index: number
id: string
match: RecipientMatch
/** 첫 줄 — 이름(없으면 주소) */
option: RecipientOption
/** 그룹 줄 — 아이콘을 앞에 */
isGroup: boolean
/** 첫 줄 — 이름(없으면 주소) · 그룹 이름 */
primary: HighlightPart[]
/** 둘째 줄 — 주소 (이름이 없으면 없다) */
/** 둘째 줄 — 주소(+ 주소록 줄의 설명) · 그룹의 구성원 수. 없으면 null */
secondary: HighlightPart[] | null
}
function optionView(match: RecipientMatch, index: number): OptionView {
const { name, email } = match.suggestion
function withDetail(parts: HighlightPart[], detail: string | null): HighlightPart[] {
return detail ? [...parts, { text: ` · ${detail}`, strong: false }] : parts
}
function optionView(option: RecipientOption, index: number): OptionView {
const id = suggestionOptionId(props.inputId, index)
if (option.kind === 'group') {
const { group, name } = option.match
return {
index,
id,
option,
isGroup: true,
primary: highlightParts(group.name, name),
secondary: [{ text: RECIPIENT_CONTACT_TEXT.groupDetail(formatCount(group.memberCount)), strong: false }],
}
}
const { suggestion } = option.match
const detail = contactSuggestionDetail(suggestion)
if (suggestion.name) {
return {
index,
id,
option,
isGroup: false,
primary: highlightParts(suggestion.name, option.match.name),
secondary: withDetail(highlightParts(suggestion.email, option.match.email), detail),
}
}
return {
index,
id: suggestionOptionId(props.inputId, index),
match,
primary: name ? highlightParts(name, match.name) : highlightParts(email, match.email),
secondary: name ? highlightParts(email, match.email) : null,
id,
option,
isGroup: false,
primary: highlightParts(suggestion.email, option.match.email),
secondary: detail ? [{ text: detail, strong: false }] : null,
}
}
/** 묶음별 추천 — 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
/** 묶음별 추천 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
const groups = computed(() => {
const options = props.matches.map(optionView)
return (['directory', 'recent'] as const)
.map((source) => ({
source,
label: GROUP_LABELS[source],
options: options.filter((option) => option.match.suggestion.source === source),
}))
.filter((group) => group.options.length > 0)
const views = props.options.map(optionView)
return GROUP_ORDER.map((source) => ({
source,
label: GROUP_LABELS[source],
options: views.filter((view) => recipientOptionGroup(view.option) === source),
})).filter((group) => group.options.length > 0)
})
</script>
@@ -93,19 +139,29 @@ const groups = computed(() => {
class="recipient-suggestions__group-label"
>{{ group.label }}</span>
<div
v-for="option in group.options"
:id="option.id"
:key="option.id"
v-for="view in group.options"
:id="view.id"
:key="view.id"
role="option"
:aria-selected="option.index === activeIndex"
:class="['recipient-suggestions__option', { 'is-active': option.index === activeIndex }]"
:aria-selected="view.index === activeIndex"
:class="[
'recipient-suggestions__option',
{ 'is-active': view.index === activeIndex, 'recipient-suggestions__option--group': view.isGroup },
]"
:data-suggestion-group="view.isGroup ? '' : undefined"
@mousedown.prevent
@mousemove="emit('activate', option.index)"
@click="emit('pick', option.match)"
@mousemove="emit('activate', view.index)"
@click="emit('pick', view.option)"
>
<AppIcon
v-if="view.isGroup"
name="users"
:size="15"
class="recipient-suggestions__group-icon"
/>
<span class="recipient-suggestions__name">
<template
v-for="(part, partIndex) in option.primary"
v-for="(part, partIndex) in view.primary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
@@ -113,11 +169,11 @@ const groups = computed(() => {
</template>
</span>
<span
v-if="option.secondary"
v-if="view.secondary"
class="recipient-suggestions__email"
>
<template
v-for="(part, partIndex) in option.secondary"
v-for="(part, partIndex) in view.secondary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
@@ -138,7 +194,7 @@ const groups = computed(() => {
<style scoped>
/*
* 추천 목록 (디자인 원본 ComposeAssist) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
* 추천 목록 (디자인 원본 ComposeAssist · ContactsMail) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
* 줄 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363) · 고른 줄 #E3F2F3 · 묶음 이름 12px #667085 굵게
*/
.recipient-suggestions {
@@ -187,10 +243,19 @@ const groups = computed(() => {
cursor: pointer;
}
/* 그룹 줄 — 아이콘이 있어 가운데로 맞춘다 (디자인 원본 ContactsMail) */
.recipient-suggestions__option--group {
align-items: center;
}
.recipient-suggestions__option.is-active {
background: var(--color-brand-subtle);
}
.recipient-suggestions__group-icon {
color: var(--color-brand-strong);
}
.recipient-suggestions__name {
flex: 0 1 auto;
min-width: 0;
@@ -20,16 +20,17 @@ describe('AppRail', () => {
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
})
it('메일 · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
it('메일 · 주소록(8단계) · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
const wrapper = await mountRail()
const current = wrapper.get('a[aria-current="page"]')
const links = wrapper.findAll('a.rail-item').map((link) => [link.text(), link.attributes('href')])
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
// 주소록은 전체 연락처, 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
expect(links).toEqual([
['메일', '/mail'],
['주소록', '/contacts'],
['설정', '/settings'],
])
})
@@ -38,7 +39,7 @@ describe('AppRail', () => {
const wrapper = await mountRail()
const disabled = wrapper.findAll('button[aria-disabled="true"]')
expect(disabled).toHaveLength(APP_MODULES.length - 2)
expect(disabled).toHaveLength(APP_MODULES.length - 3)
for (const item of disabled) {
expect(item.attributes('title')).toContain('준비 중')
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')