File name
Commit message
Commit date
File name
Commit message
Commit date
08-18
08-18
File name
Commit message
Commit date
08-14
08-14
08-14
08-18
08-18
08-18
08-14
08-14
08-14
08-14
08-18
08-18
08-14
08-18
08-18
// FoxSelect — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272)
//
// 네이티브 `<select>`가 아니라 버튼(트리거) + 리스트박스 커스텀 드롭다운이다. 옵션 목록
// 자체를 스타일링하려면 브라우저가 그리는 네이티브 팝업으로는 불가능하다.
//
// 마크업 계약 (React 밖 소비자용):
// <div class="fox-select fox-select--md">
// <label class="fox-form-label" for="sel">라벨</label> <!-- 선택 -->
// <div class="fox-select__control"> <!-- 리스트박스 위치 기준 -->
// <button class="fox-select__trigger fox-select__trigger--completed" id="sel"
// type="button" role="combobox" aria-haspopup="listbox"
// aria-expanded="true" aria-controls="sel-listbox"
// aria-activedescendant="sel-opt-0">
// <span class="fox-select__value">선택된 값 또는 플레이스홀더</span>
// <svg class="fox-select__icon">…svg…</svg>
// </button>
// <ul class="fox-select-option" id="sel-listbox" role="listbox"> <!-- 열렸을 때만 -->
// <li class="fox-select-option-item" id="sel-opt-0" role="option">…</li>
// </ul>
// </div>
// <div class="fox-select__hint">…</div> <!-- 선택 -->
// </div>
//
// 옵션 목록은 이 파일이 아니라 `fox-select-option`(판때기)과 `fox-select-option-item`
// (항목)이 소유한다. 각자의 마크업 계약은 그 파일 상단에 있다.
//
// 상태는 네이티브·ARIA 속성이 스타일 훅을 겸한다 — 상태용 클래스를 만들지 않는다.
// - 트리거: 비활성 `disabled` · 조회 전용 `disabled aria-readonly="true"`
// · 오류 `aria-invalid="true"` · 열림 `aria-expanded="true"`
//
// 네이티브 대응이 없어 클래스로 두는 하나:
// - `fox-select__trigger--completed`: 값이 선택된 상태. 미선택(플레이스홀더) 글자색과 구분한다.
//
// `fox-select__control`이 옵션 목록의 위치 기준이다(`position: relative`). `fox-select`
// 전체를 기준으로 삼으면 힌트 메시지가 있을 때 목록이 힌트 아래로 밀려 그걸 가리게 된다.
//
// 폭은 정하지 않는다 — 부모가 정한다. 높이만 `size`가 정한다.
//
// 라벨은 `fox-form-label`이 갖는다 — 시안에서 입력 계열과 같은 공용 조각이라 값이 갈라질
// 이유가 없고, 필수·선택 표시도 그쪽에 붙는다. 이 파일에는 라벨 규칙이 없다.
//
// 옵션·힌트는 `size`와 무관하게 공통 스타일이다. 크기 모디파이어는 트리거와 트리거 아이콘만
// 바꾼다 — 드롭다운 목록은 어느 크기에서 열려도 같은 서체로 읽혀야 한다.
//
// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목):
// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다.
// - box-sizing / cursor / overflow: 구조·상호작용 규칙.
@use "@fox/styles/abstracts" as fox;
.fox-select {
display: flex;
flex-direction: column;
gap: fox.gap(4);
&__control {
position: relative;
}
&__trigger {
// 포커스 시 테두리가 1px → 2px로 두꺼워져도 높이가 흔들리지 않게 한다
// (fox-button의 같은 이유 — box-sizing/_fox-button.scss 주석 참고).
box-sizing: border-box;
display: flex;
align-items: center;
gap: fox.gap(3);
inline-size: 100%;
border: fox.border(1) solid fox.color(input-border);
background: fox.color(input-surface);
// 미선택(플레이스홀더) 상태의 기본 글자색. `&--completed`가 덮어쓴다.
color: fox.color(font-neutral-subtler);
font-family: fox.font-family(body);
cursor: pointer;
// `:focus-visible`는 마우스 클릭으론 안 붙는다(브라우저 기본 동작). 열려 있는 동안은
// 클릭으로 열었어도 같은 포커스 모양이어야 하므로 `aria-expanded`도 같이 건다.
&:focus-visible,
&[aria-expanded="true"] {
border: fox.border(2) solid fox.color(input-border-active);
color: fox.color(font-neutral-strong);
}
// 포커스 표시는 위 테두리가 맡으므로 브라우저 기본 링을 걷어낸다 — 그대로 두면 시안에
// 없는 검은 링이 테두리 위에 겹쳐 그려진다.
//
// `none`이 아니라 투명 아웃라인인 이유: 강제 색상 모드(Windows 고대비)는 테두리 색을
// 시스템 색으로 덮어써 파란 테두리가 사라진다. 투명 아웃라인은 그 모드에서만 실제 링으로
// 그려져 포커스 표시가 남는다.
&:focus-visible {
outline: fox.border(2) solid transparent;
outline-offset: fox.border(1);
}
&:disabled {
cursor: not-allowed;
}
}
&__trigger--completed {
color: fox.color(font-neutral-strong);
}
&__trigger[aria-invalid="true"] {
border: fox.border(2) solid fox.color(input-border-error);
}
&__value {
flex: 1;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
// 슬롯은 `form-icon-*` 정사각형이고 아이콘 viewBox도 24×24 정사각형이라 그대로 채운다.
// 화살표 주변 여백은 SVG 안에 들어 있으므로 CSS 패딩을 따로 주지 않는다 — 그래야 크기가
// 바뀔 때 여백도 함께 비례하고, 토큰에 없는 패딩 값을 지어내지 않아도 된다.
&__icon {
flex-shrink: 0;
color: fox.color(icon-neutral-default);
}
&__hint {
display: flex;
align-items: center;
gap: fox.gap(3);
color: fox.color(font-neutral-subtle);
font-family: fox.font-family(body);
font-weight: fox.font-weight(regular);
font-size: fox.font-size(body-sm);
// line-height 150% / letter-spacing — 시안 텍스트 스타일 값이나 Figma가 변수로
// export하지 않는다(사용자 승인). 자간은 px이 아니라 em이라 크기·브레이크포인트가
// 바뀌어도 비율이 유지된다.
line-height: 1.5;
letter-spacing: -0.025em;
}
// 힌트 색은 트리거의 `aria-invalid`를 따라간다 — 오류 표시를 트리거 한 곳에만 걸면
// React를 안 쓰는 소비자가 힌트에 따로 클래스를 달지 않아도 같은 결과를 얻는다.
&:has(.fox-select__trigger[aria-invalid="true"]) {
.fox-select__hint-message {
color: fox.color(font-system-danger);
}
.fox-select__hint-icon {
color: fox.color(icon-system-danger);
}
}
&__hint-icon {
display: inline-flex;
flex-shrink: 0;
}
// ── 크기 ──────────────────────────────────────────────────────────────────
&--lg {
.fox-select__trigger {
block-size: fox.form(height-lg);
padding-inline: fox.form(padding-lg);
border-radius: fox.form(radius-lg);
font-size: fox.font-size(body-lg);
}
.fox-select__icon {
inline-size: fox.form(icon-lg);
block-size: fox.form(icon-lg);
}
}
&--md {
.fox-select__trigger {
block-size: fox.form(height-md);
padding-inline: fox.form(padding-md);
border-radius: fox.form(radius-md);
font-size: fox.font-size(body-md);
}
.fox-select__icon {
inline-size: fox.form(icon-md);
block-size: fox.form(icon-md);
}
}
&--sm {
.fox-select__trigger {
block-size: fox.form(height-sm);
padding-inline: fox.form(padding-sm);
border-radius: fox.form(radius-sm);
font-size: fox.font-size(body-sm);
}
.fox-select__icon {
inline-size: fox.form(icon-sm);
block-size: fox.form(icon-sm);
}
}
}
// 비활성 외형. `aria-invalid`(오류)와 특이도가 같아 블록 뒤에 두어야 이긴다 —
// 오류 상태에서 비활성이 되면 오류 테두리가 아니라 비활성 테두리가 보여야 한다.
.fox-select__trigger:disabled {
border: fox.border(1) solid fox.color(input-border-disabled);
background: fox.color(input-surface-disabled);
color: fox.color(font-neutral-disabled-strong);
}
// 아이콘은 비활성·조회 전용 둘 다 같은 색이다. 두 상태 모두 트리거에 `disabled`가 붙으므로
// 규칙 하나로 끝난다 — 아래 view 규칙이 글자색만 되돌리고 아이콘은 건드리지 않는다.
.fox-select__trigger:disabled .fox-select__icon {
color: fox.color(icon-neutral-disabled);
}
// view(조회 전용)는 `disabled`를 빌려 쓰지만(버튼엔 readonly 개념이 없다) 글자색만 다르다.
// 위 비활성 규칙을 이기려면 속성 하나를 더 얹어 특이도를 올려야 한다.
.fox-select__trigger:disabled[aria-readonly="true"] {
color: fox.color(font-neutral-default);
}