RailSelectors
먼저 보고, 그다음 계약을 읽습니다
레일의 세 전환기 — 서비스 · entity · 계정. 마크업까지 계약이 소유한다.
Component intent의도와 경계 읽기
왼쪽 레일 위아래 끝의 세 전환기는 모든 axelabs.ai 제품에서 같은 표면이다. 0.25.0 은 그 시각만 계약으로 올렸고 마크업 조립은 서비스마다 남아 다섯 벌로 갈라졌다. 0.29.0 의 ServiceSelect · EntitySelect · AccountSelect 는 DOM 구조 · ARIA · 열림 상태 · 키보드 · 접힌-폭 팝업을 전부 갖고, 0.30.0 은 그 위에 **구성**까지 못 박는다 — 무엇이 몇 번째 줄에 있고, 무엇을 열고, 무엇으로 정렬되는가. 순서는 최근 접속순 (`.axelabs.ai` 부모 도메인 쿠키 `axe_rail_mru`), 설정 선택은 서브메뉴가 아니라 중앙 픽커, entity 는 최근 열 개 + 검색이 붙은 전체 목록 픽커. 한 데이터 모델이 세 표면으로 나온다 — ≥1024px Radix 드롭다운, JS 없는 native <details>, <1024px native <dialog> 팝업. React 를 쓰지 않는 소비자는 src/lib/contracts/rail-selectors 의 픽스처(컴포넌트 렌더 출력)를 자기 템플릿으로 옮긴다.
예제
SSOT에 등록된 실제 API 기준 snippet입니다. standalone 배지만 독립 실행 단위이며, fragment는 주변 state·handler 문맥을 생략합니다.
import { AccountSelect, EntitySelect, ServiceSelect } from "@axe/ui";<div className="axe-workspace-rail"> <div className="axe-workspace-rail__brand"> <ServiceSelect current="gate" /> {search} </div> <div className="axe-workspace-rail__body">{nav}</div> <div className="axe-workspace-rail__identity axe-workspace-rail__identity--stacked"> <EntitySelect current={entity} items={entities} onSelect={(item) => setEntity(item.key)} /> <AccountSelect account={{ name: "Operator", email: "[email protected]" }} groups={[ { key: "session", items: [{ key: "logout", label: "Log out", href: "/logout" }] }, ]} /> </div></div>{/* 패널이 <form action>, 각 행이 <button name="org" value={key}> 제출 버튼이 된다. CSRF hidden 은 폼에 한 번만 실린다. */}<EntitySelect current={activeOrgId} items={organizations} action="/gate/org/switch" fieldName="org" fields={{ csrf: token }}/><details class="axe-workspace-entity" data-axe-selector="service"> <summary aria-label="현재 서비스: Gate. 서비스 전환"> <span class="axe-workspace-entity__label">Gate</span> <svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none"> <path d="m5.5 7.5 4.5 4.5 4.5-4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /> </svg> </summary> <div> <a class="axe-workspace-rail__link" href="https://gate.axelabs.ai" aria-current="page"> <span class="axe-workspace-entity__row"> <span class="axe-workspace-entity__copy"> <strong>Gate</strong> <small>결재와 전자서명</small> </span> </span> </a> </div></details>Props
TSX 소스가 진실입니다. 주요 export 컴포넌트의 public API만 노출합니다.
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
ServiceSelect.current | string | 필수 | — | 현재 서비스 key. 목록 밖(design·docs)이면 label 로 칩 이름만 주고 체크는 서지 않는다. |
ServiceSelect.items | PlatformService[] | — | platformServices | 목록 override. 기본 = 확정 7종. 권한 없는 서비스를 감추는 것은 소비자의 일이다. |
EntitySelect.items | EntitySelectItem[] | 필수 | — | { key, label, detail?, href? } — 전환 가능한 조직/조합/펀드 전체. 메뉴에는 최근 10개만 서고 나머지는 '더 보기' 픽커가 받는다. **제출 모드(action=)·정적 마크업에서는 캡이 없다** — 더 보기가 여는 것은 클라이언트 다이얼로그인데 그 갈래에는 그것을 열 JS 가 없어, 캡만 걸면 11번째 항목이 어떤 경로로도 닿을 수 없다. |
EntitySelect.placement | "identity" | "brand" | — | "identity" | 레일 하단 stacked identity 행(기본) 또는 상단 brand 칩. |
EntitySelect.settingsHref | string | — | — | 메뉴 바닥 '설정하기' 목적지. 없으면 그 줄을 그리지 않는다 — 갈 곳 없는 줄은 그 자체로 거짓말이다. |
EntitySelect.settingsIcon | React.ReactNode | — | <SettingsIcon /> | 그 줄의 선행 글리프. 기본은 계약의 톱니(SettingsIcon). |
EntitySelect.action | string | — | — | 제출 모드 — 패널 자체가 이 엔드포인트로 POST 하는 <form> 이 되고 각 행은 그 제출 버튼(name/value)이 된다. 행이 패널의 직계 자식으로 남아야 계약 CSS 의 `> :is(a, button)` 규칙이 적중한다. **href 가 있는 행(설정하기)은 이 모드에서도 링크다** — 폼 안이라는 이유로 submit 이 되면 설정하기가 entity=settings 를 POST 한다. 이 모드에서는 '더 보기' 행과 검색 픽커를 렌더하지 않고 목록 캡도 걸지 않는다. |
EntitySelect.fieldName | string | — | "entity" | 선택된 key 를 담는 제출 버튼의 name(제출 모드). |
EntitySelect.fields | Record<string, string> | — | — | 폼이 한 번 함께 보내는 hidden 필드(CSRF 토큰 등). 행마다 반복되지 않는다. |
AccountSelect.account | { name: string; email?: string; initial?: string } | null | — | — | 로그인한 계정. null 이면 게스트 — 칩·아바타·헤더·aria-label 이 함께 갈린다. 메뉴 헤더는 이메일 한 줄(회색·비링크)이다. |
AccountSelect.choices | AccountSelectChoice[] | — | — | 중앙 픽커를 여는 설정 행(언어·디스플레이). { key, label, icon?, value, options, onSelect }. 행 오른쪽 끝에 현재값이 회색으로 붙는다 — 메뉴를 열지 않고도 지금 값을 읽는다. |
AccountSelect.groups | RailSelectGroup[] | — | — | 액션 묶음(로그아웃 · 다른 계정으로 로그인). separated 로 구분선 위치를 정한다 — 확정 구성에서는 마지막 하나뿐이다. |
mru | readonly string[] | — | — | 서버가 읽은 최근 접속 순서(`parseRailMru(cookieHeader)`). 쿠키 `axe_rail_mru` 는 percent-encode 된 CSV 라 비-JS 소비자는 percent-decode 한 번 후 split(",") 한다. 키 문법은 `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`(RAIL_MRU_KEY_PATTERN) — 벗어난 키는 기록되지도 읽히지도 않는다. 파서는 손상된 값에도 던지지 않는다(공유 쿠키 한 번 오염이 전 표면을 죽이지 않게). SSR 소비자는 이걸 넘겨 첫 렌더부터 같은 순서를 낸다. 정적 소비자는 생략 — 그때는 서버·클라이언트 첫 렌더가 똑같이 알파벳순이고 쿠키 순서는 mount 뒤에 온다. |
onSelect | (item: RailSelectItem, group?: RailSelectGroup) => void | — | — | 행 선택 알림. 이동은 item.href 가 맡으므로 여기서 다시 이동시키지 않는다(가로채려면 href 를 주지 말 것). |
collapsed | boolean | — | — | <1024px 여부. 생략 시 matchMedia 로 자체 감지. |
staticMarkup | boolean | — | false | JS 없는 native <details> 마크업으로 렌더. 제출 모드면 자동으로 켜진다. |
defaultOpen | boolean | — | — | native <details>/<dialog> 의 초기 열림. 정적 마크업·픽스처 전용. |
id | string | — | — | 묶음 라벨 id 의 접두사. 생략하면 React useId 가 렌더 트리마다 다른 값을 낸다 — 계약 픽스처처럼 바이트가 같아야 하는 곳에서만 고정한다. |
.axe-* 클래스 계약
React 밖에서도 같은 표면을 그리는 공개 계약입니다. stable은 minor 버전 안에서 이름을 바꾸지 않습니다.
| 클래스 | 안정성 | 용도 |
|---|---|---|
.axe-workspace-entity | stable | brand 칩 — ServiceSelect 와 placement="brand" 의 EntitySelect. <details> root 또는 <button>. |
.axe-workspace-entity__label | stable | brand 칩의 말줄임 라벨. |
.axe-workspace-entity__menu | stable | **portal 전용** 드롭다운 표면(Radix DropdownMenuContent 에 얹는다). native <details> 갈래의 brand 패널은 클래스를 갖지 않는다 — 계약이 `details.axe-workspace-entity > :not(summary)` 로 요소 한정해 잡는다. |
.axe-workspace-entity__row | stable | 두 줄 옵션의 2열 격자. 한 줄 액션 행에는 쓰지 않는다(없는 우측 슬롯을 만든다). |
.axe-workspace-entity__value | stable | 설정 행 오른쪽 끝의 현재값 요약(회색). Radix 갈래에서는 item-label 이 :has() 로 flex 가 된다. |
.axe-workspace-entity__copy | stable | 옵션 이름(strong)과 설명(small) 스택. 계정 메뉴 헤더도 같은 쌍을 쓴다. |
.axe-workspace-identity | stable | identity 칩 — AccountSelect 트리거이자 EntitySelect 의 기본 자리. |
.axe-workspace-identity--entity | stable | identity 칩의 entity 변형 — 선행 18px 마크 + 후행 chevron. |
.axe-workspace-identity__avatar | stable | 18px 계정 아바타(이니셜). |
.axe-workspace-identity__label | stable | identity 칩의 말줄임 라벨. |
.axe-workspace-identity-menu | stable | identity 칩의 native <details> root — 위로 열린다. |
.axe-workspace-identity-menu__panel | stable | 계정/entity 메뉴 표면(portal · in-flow 공용). in-flow 갈래는 위로 열리는 위치를 스스로 갖고, 제출 모드에서는 이 클래스를 <form> 이 받는다. |
.axe-workspace-identity-menu__submenu | stable | 라디오 묶음(언어·표시)의 데스크톱 서브패널. |
.axe-workspace-rail__link | stable | native <details> 갈래의 행. 목적지가 있으면 <a href>, 상태를 바꾸면 <button>, 제출 모드면 패널-폼의 submit 버튼. |
.axe-workspace-rail__group-label | stable | native 패널 안의 group caption. |
.axe-workspace-picker-dialog | stable | 접힌 폭 팝업 — 트리거 바로 옆에 렌더되는 native <dialog>. 닫혀 있으면 display:none. |
.axe-workspace-picker | stable | 팝업 패널. |
.axe-workspace-picker__header | stable | 팝업 제목 + 닫기 버튼 행. |
.axe-workspace-picker__close | stable | 44px 팝업 닫기 버튼. |
.axe-workspace-picker__list | stable | 팝업의 유일한 세로 scroll 영역. 제출 모드에서는 이 클래스를 <form> 이 받는다. |
.axe-dropdown-menu__separator | stable | 묶음 사이 구분선. 세 표면이 같은 자리에 갖는다 — identity 패널의 규칙은 workspace-shell.css(= core-data 프로파일 포함)가 재선언한다. |
.axe-workspace-picker__label | stable | 팝업 헤더 caption 과 group caption(role="group" 의 이름 노드). |
.axe-workspace-picker__item | stable | 44px 이상 팝업 행. 현재 항목은 aria-current + 체크 글리프. |
접근성
키보드, ARIA, 구현 노트를 함께 검토합니다.
- 칩에서 Enter/Space 로 열기 — 데스크톱은 드롭다운, <1024px 는 팝업
- 드롭다운: ↑/↓ 이동 · Enter 선택 · Esc 닫고 칩으로 포커스 복원 · type-ahead (Radix)
- 목적지가 있는 행은 진짜 <a href> — cmd/ctrl-클릭 · 가운데 클릭 · "새 탭에서 열기" 가 그대로 산다
- 설정 행(언어 · 디스플레이)은 값을 바꾸지 않고 중앙 픽커를 연다 — 데스크톱과 접힌 폭이 같은 화면이다
- 그 픽커는 라디오라 선택해도 닫히지 않는다 — 연속으로 고를 수 있다
- entity '더 보기' 픽커는 돋보기와 같은 검색 입력(.axe-cmdk__input)을 갖는다
- 라디오 묶음 서브메뉴: →/Enter 로 열고 ← 로 돌아온다
- 팝업: Esc 와 backdrop 클릭이 닫는다(native <dialog>). 포커스 트랩·복원은 브라우저
- 서랍 위에 팝업이 떠 있는 동안 Esc 는 팝업만 닫는다 — window capture 에서 전파만 끊고 default 는 건드리지 않는다
칩은 <button aria-label>(현재 값을 문장으로 읽는다) 또는 native <summary>. 현재 항목은 aria-current — 서비스 전환은 이동이라 "page", entity·계정은 상태라 "true". 팝업은 <dialog aria-label>, 라디오 묶음은 role="group" + aria-labelledby. 세 전환기는 DOM 에 data-axe-selector="service|entity|account" 를 갖는다 — 계약 픽스처와 소비자 게이트의 기계 식별자이며 CSS 는 이 속성을 쓰지 않는다.
팝업은 트리거 바로 옆에 렌더한다. 서랍(Sheet) 안에서 열릴 때 둘이 같은 DOM 이어야 서랍의 modal 장치가 팝업을 바깥으로 취급하지 않고, showModal() 이 기억한 트리거가 살아 있어 브라우저가 포커스를 돌려준다. 게스트(로그아웃) 상태는 예외가 아니라 계약된 상태다 — account={null} 하나로 칩·아바타·헤더·aria-label 이 함께 갈린다.