검색창 이력 · 대상 선택기 (SearchInput · EntityPicker)
검색창에 「최근·자주 쓰는 검색어」를 붙이는 dsSearchInput 과, 직원·팀을 검색해 고르는 entityPicker 두 공통 컴포넌트의 사용 기준과 마크업입니다.
Alpine + CSS · Web · 2026-10 추가 (10/6 병원 요구 MED-918 · MED-919 · MED-920)
규칙 (강제) · 코딩 에이전트용
- 목록 화면의 검색창에는 이력을 붙입니다. 새 검색창(
.ds-filter-search안의<input>포함)은 감싸는 요소에x-data="dsSearchInput({ context })"+x-modelable="value" x-model="<호스트 필드>"를 얹고<input>은x-model="value"·@focus="onFocus($el)"·@blur="onBlur()"·@keydown="onKeydown($event)"를 받습니다. 드롭다운 마크업은packages/components/html/ds-search-input.html한 벌을 include 하고 직접 그리지 않습니다. 기존@input.debounce핸들러는 그대로 둡니다(제안을 고르면input이벤트를 다시 쏘아 그 핸들러가 돕니다). - 컨텍스트 이름은
<앱>.<화면>소문자 점 표기(admin.employees,drive.files,sheet.rows). 화면마다 다른 이름을 써야 다른 화면의 검색어가 섞이지 않습니다. 같은 필드를 쓰는 같은 화면이면 같은 이름. 형식은isValidSearchHistoryContext가 검사합니다. - 이력은 서버 저장(
user_search_history, 사용자 × 컨텍스트)이고 민감 패턴(주민번호·계좌·카드·전화번호)은 저장하지 않습니다 — 판정은shouldRecordSearchTerm(@hereby/core/constants) 한 곳. 브라우저와 서버가 같은 함수를 씁니다. 1글자 검색어도 저장하지 않습니다. 로컬 스토리지에 검색어를 따로 쌓지 마세요. - 저장 시점은 「확정」 — Enter · 제안 선택 · 값이 있는 채로 포커스가 나갈 때. 자동 검색 화면은 Enter 를 누르지 않으므로 blur 가 사실상의 확정입니다. 타이핑 중간마다 저장하지 않습니다.
- 드롭다운은
.ds-menu.ds-search-pop—<body>로 텔레포트한 고정 위치 패널(날짜·시간 피커와 같은 방식)이라 필터 바의 접기·모달의 overflow 에 잘리지 않습니다. 층은--ds-z-modal-top(모달 안 검색창에서도 떠야 함). 묶음은 자주 찾는 검색어(즐겨찾기 먼저 · 그 뒤 상위 5 · 2회 이상) → 최근 검색어(최대 10) → 입력 중엔 추천(이력 + 데이터 후보 최대 8) 순이고 이 숫자는SEARCH_HISTORY_*상수에 있습니다. 항목별 X 와 「검색 이력 전체 지우기」는 패널이 가집니다. - 이력 항목 앞의 별은 즐겨찾기 토글 버튼(
.ds-search-pop__pin,togglePin) — 누르면 검색으로 넘어가지 않고(@click.stop) 그 검색어를 고정/해제합니다. 고정된 검색어는 사용 횟수와 무관하게 「자주 찾는 검색어」 맨 위에 남고 개수 제한에 잘리지 않습니다(user_search_history.pinned,searchHistory.pin). 별을 장식 아이콘으로 다시 그리지 마세요 — 2026-10-12 직원 관리에서 별을 누르면 검색으로 넘어가는 리포트가 있었습니다. - 같은 자리에 다른 팝오버가 있으면 그 팝오버의
x-show에&& !dropdownVisible을 더해 두 패널이 겹치지 않게 합니다(시트 행 검색의 「다른 시트에서 검색」이 그 예). 아래에 결과 목록이 이미 있는 자리(대상 선택기)는openOnFocus: false로 입력이 있을 때만 제안합니다. - 사람·팀을 「찾아서 고르는」 자리는
entityPicker— 공유 대상·결재선·발송 대상·팀 이동처럼 후보가 수십 개를 넘는 곳에<select>를 새로 두지 않습니다. 호스트에x-data="entityPicker({ context, kinds })"+x-modelable="selected" x-model="<호스트 배열>"을 얹고packages/components/html/entity-picker.html을 include 합니다. 이미 고른·고를 수 없는 대상은x-effect="setExcluded(ids)"로 넘기면 흐리게(disabled) 남습니다 — 숨기지 않습니다. - 저장은
flattenEntitySelection(selected)로 평면 목록을 만들어 합니다. 「하위 팀 포함」을 켠 팀은 하위 팀이 각각 한 줄로 펼쳐집니다 — 드라이브·시트 공유의 팀 grant 는 하위 팀을 자동으로 따르지 않기 때문입니다. 저장이 끝나면selected = []로 비웁니다. - 후보는 서버 디렉터리(
entityPicker.directory) 한 곳에서 옵니다 — 활성 팀 전체(트리) + 재직 직원(이름·사번·소속·직무). 직원 이름·소속은 메신저 연락처와 같은 범위(조직 멤버 누구나), 사번은employee:read(어떤 범위든) 보유자에게만 내려갑니다. 화면이team.list·employee.contacts를 따로 모아 후보를 만들지 마세요. - 클래스는
.ds-entity-picker*·.ds-entity-chip*·.ds-search-pop*만 쓰고 정본은packages/static/styles/design-system.css. 앱main.css나 파셜<style>에 다시 쓰지 마세요. 체크 아이콘·칩·행 하이라이트에 색을 따로 주지 않습니다. - 다크 모드(2026-10-13 · 10-19) — 소속 칩(
.ds-member-chip·__seg--dept/team/role)의 면 · 글자 · hover 틴트는bg-container/bg-alternative/bg-sunken/bg-hover·label-body/label-normal, 삭제 × · 구분점은label-disable(다크 450), 엔티티 피커 · 검색 팝업 행 hover 는label-normal4% 틴트(전 고정rgba(27,27,27,.04))를 읽는다. 이 페이지에는 Playground 가 없어 다크 미리보기가 없다 — 앱(직원 앱 「결재자 변경」 · 관리자 직원 추가 패널)에서 확인한다.
검색창 이력 (dsSearchInput)
쓰는 법
필터 바의 검색창은 감싸는 .ds-filter-search 가 호스트가 됩니다. x-model 은 호스트 요소로 옮기고 입력칸은 value 에 묶습니다.
<div class="ds-filter-search"
x-data="dsSearchInput({ context: 'admin.employees' })"
x-modelable="value" x-model="employeeFilter.search">
<input type="text" x-model="value" class="ds-input--32"
@input.debounce.300ms="loadEmployees()"
autocomplete="off" readonly @focus="$el.removeAttribute('readonly'); onFocus($el)"
@blur="onBlur()" @keydown="onKeydown($event)" />
<!-- include "../../packages/components/html/ds-search-input.html" -->
</div>옵션:
| 옵션 | 뜻 |
|---|---|
context (필수) | 저장 컨텍스트. <앱>.<화면> 소문자 점 표기 |
suggest(query) | 입력 중 함께 제안할 데이터 후보(현재 목록의 이름·사번 등)를 돌려주는 함수. 없으면 이력만 제안 |
openOnFocus | false 면 포커스만으로 최근·자주 묶음을 열지 않고 입력이 있을 때만 제안(아래에 결과 목록이 있는 자리) |
컴포넌트가 노출하는 것: value(검색어) · sections(드롭다운 묶음) · dropdownVisible · pick(term) · remove(term) · clearAll().
동작
- 포커스 → 이력을 한 번 읽어(컨텍스트별 캐시) 자주·최근 묶음을 보여 줍니다. 입력 중엔 이력에서 앞부분 일치 → 포함 일치 → 데이터 후보 순으로 최대 8개.
- ↑↓ 로 이동, Enter 로 선택(선택 중이 아니면 확정), Esc 로 닫기. 항목의 X 는 그 검색어만, 하단 「검색 이력 전체 지우기」는 컨텍스트 전부.
- 민감 패턴을 입력하면 저장하지 않고 패널 아래에 「주민번호·계좌·전화번호 같은 민감한 검색어는 이력에 저장하지 않아요.」를 보여 줍니다.
- 서버 호출(
searchHistory.list/record/remove/clear)은 모두 조용히 실패합니다 — 이력은 편의 기능이라 검색 자체를 막지 않습니다.
대상 선택기 (entityPicker)
쓰는 법
<div x-data="entityPicker({ context: 'drive-share', kinds: ['team', 'employee'] })"
x-modelable="selected" x-model="grantPicker.selected"
x-effect="setExcluded(grantedIds)">
<!-- include "../../packages/components/html/entity-picker.html" -->
</div>
<select x-model="grantForm.role" class="ds-select--32">…</select>
<button :disabled="grantPicker.selected.length === 0" @click="addGrants()">추가</button>import { flattenEntitySelection } from "@hereby/core/constants";
async addGrants() {
for (const t of flattenEntitySelection(this.grantPicker.selected)) {
await sdk.teamDrive.addGrant({ …, granteeType: t.kind, granteeId: t.id, role });
}
this.grantPicker.selected = [];
}옵션:
| 옵션 | 뜻 |
|---|---|
context (필수) | 화면 이름(drive-share, sheet-access). 최근 선택·검색어 이력의 컨텍스트가 됩니다 |
kinds | 고를 수 있는 종류 ['team', 'employee'](기본). 첫 번째가 처음 열리는 탭. 하나만 주면 탭이 사라집니다 |
multiple | 기본 true. false 면 하나 고르면 이전 선택을 바꿉니다 |
subTeams | 기본 true. 팀 칩의 「하위 팀 포함」 체크를 끄려면 false |
placeholder | 검색창 안내 글 |
구성
- 칩 — 고른 대상. 팀 칩에는 하위 팀이 있을 때 「하위 팀 포함」 체크가 붙고, X 로 해제, 둘 이상이면 「모두 해제」.
- 검색 + 탭 — 검색창은
dsSearchInput(picker.<context>.search, 입력 중에만 제안)을 품고, 탭은 팀/개인. - 후보 목록(
max-height: 240px) — 검색 전엔 「최근 선택」(최대 5)이 먼저, 그 아래 전체. 팀은 트리(깊이만큼 들여쓰기, 하위 팀 수), 개인은 「이름 · 사번 · 팀 · 직무」 앞 50명. 이미 추가된 대상은 흐리게.
데이터
- 후보:
entityPicker.directory({ kinds })→{ teams[], employees[], employeeNumberVisible }. 페이지 단위로 캐시(모달을 다시 열어도 한 번만 읽음,resetEntityPickerCache()로 비움). - 최근 선택: 검색어 이력과 같은 저장소(
picker.<context>.<kind>, term = 대상 id). 고를 때 기록합니다. - 순수 로직(
buildTeamTreeRows·filterTeamTreeRows·filterEmployees·expandEntitySelection·flattenEntitySelection)은packages/core/src/constants/entity-picker.ts에 있고 테스트가 붙어 있습니다.
호스트가 후보를 넣는 쓰임새 — 결재자 고르기
서버 디렉터리(전 직원) 대신 호스트가 이미 추린 후보를 보여 줄 때는 employees 옵션으로 목록을 넣습니다. 그러면 entityPicker.directory 를 부르지 않고 「최근 선택」도 두지 않습니다(목록이 짧습니다). 사번을 비워 주면 사번 없이 「팀 · 직무」만 보입니다. 고른 순서가 뜻을 가지면 ordered: true 로 칩에 1 · 2 · 3 번호가 붙고, max 를 주면 상한을 채웠을 때 남은 행이 비활성이 됩니다.
<!-- 결재선(MED-900) — 후보는 approvalFlow.lineContext 가 준 「이 신청을 결재할 수 있는 사람」(이름 · 팀 · 직책) -->
<div x-data="entityPicker({ context: 'approval-line', kinds: ['employee'], employees: pickerEmployees, ordered: true, max: 5 })"
x-modelable="selected" x-model="approverPicker.selected">
<!-- include "../../packages/components/html/entity-picker.html" -->
</div>규칙: ordered 일 때만 칩에 순서 조정 ▲▼(.ds-entity-chip__move, moveSelection(s, ±1))가 붙습니다 — 첫 칩의 ▲ 와 마지막 칩의 ▼ 는 비활성이고, 끌어서 옮기기는 두지 않습니다(모바일 스크롤과 겹칩니다). ordered 가 아니면 ▲▼ 가 없습니다. 사번·연락처를 employeeNumber·jobDuty 에 넣어 노출하지 않습니다(직원 검색 노출 범위 — 결재선 결정 8번).
적용 화면
드라이브 권한 관리(폴더·파일 grant 추가) · 시트 접근 권한(공유 대상 추가). 결재선 직접 선택·앱 안내 발송 대상·팀 이동은 각 티켓에서 이 컴포넌트로 교체합니다.