Skip to content

검색창 이력 · 대상 선택기 (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-normal 4% 틴트(전 고정 rgba(27,27,27,.04))를 읽는다. 이 페이지에는 Playground 가 없어 다크 미리보기가 없다 — 앱(직원 앱 「결재자 변경」 · 관리자 직원 추가 패널)에서 확인한다.

검색창 이력 (dsSearchInput) ​

쓰는 법 ​

필터 바의 검색창은 감싸는 .ds-filter-search 가 호스트가 됩니다. x-model 은 호스트 요소로 옮기고 입력칸은 value 에 묶습니다.

html
<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)입력 중 함께 제안할 데이터 후보(현재 목록의 이름·사번 등)를 돌려주는 함수. 없으면 이력만 제안
openOnFocusfalse 면 포커스만으로 최근·자주 묶음을 열지 않고 입력이 있을 때만 제안(아래에 결과 목록이 있는 자리)

컴포넌트가 노출하는 것: value(검색어) · sections(드롭다운 묶음) · dropdownVisible · pick(term) · remove(term) · clearAll().

동작 ​

  • 포커스 → 이력을 한 번 읽어(컨텍스트별 캐시) 자주·최근 묶음을 보여 줍니다. 입력 중엔 이력에서 앞부분 일치 → 포함 일치 → 데이터 후보 순으로 최대 8개.
  • ↑↓ 로 이동, Enter 로 선택(선택 중이 아니면 확정), Esc 로 닫기. 항목의 X 는 그 검색어만, 하단 「검색 이력 전체 지우기」는 컨텍스트 전부.
  • 민감 패턴을 입력하면 저장하지 않고 패널 아래에 「주민번호·계좌·전화번호 같은 민감한 검색어는 이력에 저장하지 않아요.」를 보여 줍니다.
  • 서버 호출(searchHistory.list/record/remove/clear)은 모두 조용히 실패합니다 — 이력은 편의 기능이라 검색 자체를 막지 않습니다.

대상 선택기 (entityPicker) ​

쓰는 법 ​

html
<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>
ts
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검색창 안내 글

구성 ​

  1. 칩 — 고른 대상. 팀 칩에는 하위 팀이 있을 때 「하위 팀 포함」 체크가 붙고, X 로 해제, 둘 이상이면 「모두 해제」.
  2. 검색 + 탭 — 검색창은 dsSearchInput(picker.<context>.search, 입력 중에만 제안)을 품고, 탭은 팀/개인.
  3. 후보 목록(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 를 주면 상한을 채웠을 때 남은 행이 비활성이 됩니다.

html
<!-- 결재선(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 추가) · 시트 접근 권한(공유 대상 추가). 결재선 직접 선택·앱 안내 발송 대상·팀 이동은 각 티켓에서 이 컴포넌트로 교체합니다.