선택 필드 공통 규약 (PICKER_CONVENTION)
| 항목 | 내용 |
|---|---|
| 상태 | 규약(신규) — 첫 소비자는 PortalConsole 조직·사용자 화면 |
| 범위 | 계층이 있거나 목록이 긴 값을 필드 + 레이어 팝업으로 고르는 공통 UI 규약 |
| 관련 | component-library-design.md §6.9, REFERENCE_DATA_SUPPLY_CONVENTION.md(L1 식별자 인용) |
1. 왜 규약인가
계층이 있는 값(부서)을 좁은 select 목록에 평면으로 넣으면 세 가지가 무너진다.
- 맥락이 사라진다 —
경영관리팀이 어느 조직 산하인지 목록에서 알 수 없다. - 고르는 비용이 값 수에 비례해 커진다 — 검색어를 모르면 스크롤로 찾아야 한다.
- 같은 실수를 화면마다 반복한다 — 부서 선택이 필요한 화면마다 다른 목록·다른 필터를 만든다.
그래서 고르는 UI 를 필드(값 표시)와 팝업(고르기)으로 분리하고, 공통이 그 둘을 소유한다.
2. 구성 (foundation @hohyun94/portal-foundation)
| 컴포넌트 | 역할 |
|---|---|
PortalPickerField | 값 + 선택/변경/지우기 버튼만 가진 필드 셸. 라벨·필수 표시·설명·오류는 기존 PortalFieldProps 계약을 그대로 쓴다. 값이 여럿이면 variant="chips" |
PortalTreePickerDialog | 검색 입력 + PortalTree + 확정/취소를 가진 레이어 팝업(트리에서 하나 고르기) |
PortalOrgPicker | 위 둘을 합친 조직(부서) 선택 필드 — 값을 경로로 보여 준다 |
PortalUserPicker / PortalUserPickerDialog | 사용자 선택 필드·팝업 — 좌 조직 트리(필터) + 우 검색·목록, mode="multiple" 이면 여러 명을 칩으로 보여 준다 |
buildPickerTree / findPickerPath / findPickerNode / pickerPathLabel | 평면 목록(parentId)→트리 변환, 경로·노드 조회, 경로 표시 |
3. 데이터 계약
- 소비 포털이 데이터를 주입한다. foundation 은 fetch·캐시를 소유하지 않는다(
nodes,loading을 받는다). 포털은 자기 tRPC·REST 로 조직 목록을 가져와buildPickerTree로 트리를 만든다. - 식별자의 의미는 호출자가 정한다. 콘솔은 내부 부서 id, L2 도메인은 부서코드를 쓴다 — 공통 UI 는
nodes[].id를 해석하지 않는다(값을 복사·재정의하지 않는다는 L1 인용 규칙을 UI 계층에서 지킨다). - 부모를 찾지 못한 행은 버린다 대신 최상위로 올린다 — 조직 개편·부분 동기화 중에도 화면은 열려야 한다.
- 트리에 없는 값(지워진 부서)은 경로를 만들지 못하므로 필드는
placeholder를 보여 준다(예외를 던지지 않는다).
4. 동작 규칙
| 규칙 | 이유 |
|---|---|
| 팝업에서 고른 값은 확정 전까지 임시다 — 취소하면 원래 값이 유지된다 | 잘못 눌러 값이 바뀌는 사고를 막는다 |
| 검색은 맞은 노드와 그 조상 경로만 남기고 그 경로를 자동으로 펼친다 | 계층 맥락을 잃지 않는다 |
| 값이 있으면 그 경로를 펼친 채로 연다. 값이 없으면 최상위 조직을 펼친다 | 현재 값의 위치, 조직 구조를 바로 보여 준다 |
| 트리 영역 Enter 로 확정하고, 검색 입력의 Enter 는 확정하지 않는다 | 검색 중 실수 확정을 막는다 |
| 트리는 읽기 전용 선택만 노출한다(체크박스·드래그 편집 없음) | 편집·재정렬은 화면(소유자)의 일이다 |
값 표시는 경로(상위 > 하위 > 팀)다 | 평면 목록에서 잃던 계층을 되돌린다 |
| 사용자 팝업의 조직 필터는 고른 조직의 하위 조직원까지 포함한다 | 상위 조직을 고르면 그 산하 전원을 봐야 한다 |
| 고를 수 없는 사람(예: 이미 구성원)은 숨기지 않고 이유와 함께 비활성으로 보여 준다 | 목록에서 사라지면 "그 사람이 왜 없지"를 찾아 헤맨다 |
| 사용자 값이 목록에 없으면(권한 밖·비활성) id 를 그대로 보여 준다 | 값이 화면에서 조용히 사라지면 저장 시 무엇을 덮어쓰는지 알 수 없다 |
검색을 호스트가 소유할 수 있다(searchValue·onSearchChange) — 그때 텍스트 필터는 호스트(서버) 몫이고 부서 범위만 컴포넌트가 적용한다 | 서버가 화면이 모르는 필드(직책·사번)로 찾을 수 있게 하고, 결과를 두 번 거르지 않는다 |
| 사용자 목록 영역은 처음 열린 높이로 고정한다 | 검색 결과 수에 따라 팝업 전체 높이가 흔들리지 않는다 |
| 팝업 하단 액션은 본문과 같은 인셋을 쓴다 | 취소·확인 버튼이 팝업 가장자리에 붙지 않는다 |
5. 접근성
- 라벨·필수 표시·설명·오류는
PortalFieldProps하나를 쓴다(필드마다 다시 만들지 않는다). - 팝업 버튼에
aria-haspopup="dialog"와aria-expanded(열림 상태)를 준다. - 검색 입력에는
aria-label을 준다(아이콘만 있는 입력 금지). - 트리 영역은 키보드 포커스를 받고, 스크롤 영역(
.pf-picker-dialog__tree)으로 긴 목록에서도 팝업이 화면을 넘지 않는다.
6. 검증
bash
cd portal-foundation
pnpm --filter @hohyun94/portal-foundation check
pnpm --filter @hohyun94/portal-foundation exec vitest run src/pickerComponents.test.tsx src/pickerComponents.browser.test.tsx- 순수 함수·정적 계약:
packages/ui-react/src/pickerComponents.test.tsx - 팝업 상호작용(검색·확정·취소 원복·Enter):
packages/ui-react/src/pickerComponents.browser.test.tsx
7. 남은 일
- 사용자 목록은 검색어를 서버로 넘길 수 있다(
searchValue·onSearchChange). 소비 포털이 자기 조회를 붙이면 수천 명 규모에서도 찾을 수 있다 — 컴포넌트가 스스로 조회하지는 않는다(소유권 규약). - 조직 트리는 지금 한 번에 받는다(조직 수는 사람 수보다 훨씬 적다). 수천 조직이 확인되면
Tree지연 로딩을 추가한다. - 선택 이력(최근 고른 사람)·즐겨찾기는 수요가 확인되면 추가한다.