본문으로 건너뛰기
내비게이션 / rail-selectors

RailSelectors

레일의 세 전환기 — 서비스 · entity · 계정. 마크업까지 계약이 소유한다.
내비게이션rail-selectorsStatic specimenrail레일service select서비스 전환
01 · Specimen

먼저 보고, 그다음 계약을 읽습니다

레일의 세 전환기 — 서비스 · entity · 계정. 마크업까지 계약이 소유한다.

Workbench 불러오는 중…
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 의 픽스처(컴포넌트 렌더 출력)를 자기 템플릿으로 옮긴다.

02 · Use

예제

SSOT에 등록된 실제 API 기준 snippet입니다. standalone 배지만 독립 실행 단위이며, fragment는 주변 state·handler 문맥을 생략합니다.

01레일 세 전환기tsxfragment
레일 세 전환기
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>
02서버가 상태를 갖는 entity 전환 (제출 모드)tsxfragment
서버가 상태를 갖는 entity 전환 (제출 모드)
{/* 패널이 <form action>, 각 행이 <button name="org" value={key}> 제출 버튼이 된다.    CSRF hidden 은 폼에 한 번만 실린다. */}<EntitySelect  current={activeOrgId}  items={organizations}  action="/gate/org/switch"  fieldName="org"  fields={{ csrf: token }}/>
03CSS-only (비-React) — native <details> 갈래htmlfragment
CSS-only (비-React) — native <details> 갈래
<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>
03 · React

Props

TSX 소스가 진실입니다. 주요 export 컴포넌트의 public API만 노출합니다.

이름타입필수기본값설명
ServiceSelect.currentstring필수현재 서비스 key. 목록 밖(design·docs)이면 label 로 칩 이름만 주고 체크는 서지 않는다.
ServiceSelect.itemsPlatformService[]platformServices목록 override. 기본 = 확정 7종. 권한 없는 서비스를 감추는 것은 소비자의 일이다.
EntitySelect.itemsEntitySelectItem[]필수{ key, label, detail?, href? } — 전환 가능한 조직/조합/펀드 전체. 메뉴에는 최근 10개만 서고 나머지는 '더 보기' 픽커가 받는다. **제출 모드(action=)·정적 마크업에서는 캡이 없다** — 더 보기가 여는 것은 클라이언트 다이얼로그인데 그 갈래에는 그것을 열 JS 가 없어, 캡만 걸면 11번째 항목이 어떤 경로로도 닿을 수 없다.
EntitySelect.placement"identity" | "brand""identity"레일 하단 stacked identity 행(기본) 또는 상단 brand 칩.
EntitySelect.settingsHrefstring메뉴 바닥 '설정하기' 목적지. 없으면 그 줄을 그리지 않는다 — 갈 곳 없는 줄은 그 자체로 거짓말이다.
EntitySelect.settingsIconReact.ReactNode<SettingsIcon />그 줄의 선행 글리프. 기본은 계약의 톱니(SettingsIcon).
EntitySelect.actionstring제출 모드 — 패널 자체가 이 엔드포인트로 POST 하는 <form> 이 되고 각 행은 그 제출 버튼(name/value)이 된다. 행이 패널의 직계 자식으로 남아야 계약 CSS 의 `> :is(a, button)` 규칙이 적중한다. **href 가 있는 행(설정하기)은 이 모드에서도 링크다** — 폼 안이라는 이유로 submit 이 되면 설정하기가 entity=settings 를 POST 한다. 이 모드에서는 '더 보기' 행과 검색 픽커를 렌더하지 않고 목록 캡도 걸지 않는다.
EntitySelect.fieldNamestring"entity"선택된 key 를 담는 제출 버튼의 name(제출 모드).
EntitySelect.fieldsRecord<string, string>폼이 한 번 함께 보내는 hidden 필드(CSRF 토큰 등). 행마다 반복되지 않는다.
AccountSelect.account{ name: string; email?: string; initial?: string } | null로그인한 계정. null 이면 게스트 — 칩·아바타·헤더·aria-label 이 함께 갈린다. 메뉴 헤더는 이메일 한 줄(회색·비링크)이다.
AccountSelect.choicesAccountSelectChoice[]중앙 픽커를 여는 설정 행(언어·디스플레이). { key, label, icon?, value, options, onSelect }. 행 오른쪽 끝에 현재값이 회색으로 붙는다 — 메뉴를 열지 않고도 지금 값을 읽는다.
AccountSelect.groupsRailSelectGroup[]액션 묶음(로그아웃 · 다른 계정으로 로그인). separated 로 구분선 위치를 정한다 — 확정 구성에서는 마지막 하나뿐이다.
mrureadonly 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 를 주지 말 것).
collapsedboolean<1024px 여부. 생략 시 matchMedia 로 자체 감지.
staticMarkupbooleanfalseJS 없는 native <details> 마크업으로 렌더. 제출 모드면 자동으로 켜진다.
defaultOpenbooleannative <details>/<dialog> 의 초기 열림. 정적 마크업·픽스처 전용.
idstring묶음 라벨 id 의 접두사. 생략하면 React useId 가 렌더 트리마다 다른 값을 낸다 — 계약 픽스처처럼 바이트가 같아야 하는 곳에서만 고정한다.
04 · Any stack

.axe-* 클래스 계약

React 밖에서도 같은 표면을 그리는 공개 계약입니다. stable은 minor 버전 안에서 이름을 바꾸지 않습니다.

클래스안정성용도
.axe-workspace-entitystablebrand 칩 — ServiceSelect 와 placement="brand" 의 EntitySelect. <details> root 또는 <button>.
.axe-workspace-entity__labelstablebrand 칩의 말줄임 라벨.
.axe-workspace-entity__menustable**portal 전용** 드롭다운 표면(Radix DropdownMenuContent 에 얹는다). native <details> 갈래의 brand 패널은 클래스를 갖지 않는다 — 계약이 `details.axe-workspace-entity > :not(summary)` 로 요소 한정해 잡는다.
.axe-workspace-entity__rowstable두 줄 옵션의 2열 격자. 한 줄 액션 행에는 쓰지 않는다(없는 우측 슬롯을 만든다).
.axe-workspace-entity__valuestable설정 행 오른쪽 끝의 현재값 요약(회색). Radix 갈래에서는 item-label 이 :has() 로 flex 가 된다.
.axe-workspace-entity__copystable옵션 이름(strong)과 설명(small) 스택. 계정 메뉴 헤더도 같은 쌍을 쓴다.
.axe-workspace-identitystableidentity 칩 — AccountSelect 트리거이자 EntitySelect 의 기본 자리.
.axe-workspace-identity--entitystableidentity 칩의 entity 변형 — 선행 18px 마크 + 후행 chevron.
.axe-workspace-identity__avatarstable18px 계정 아바타(이니셜).
.axe-workspace-identity__labelstableidentity 칩의 말줄임 라벨.
.axe-workspace-identity-menustableidentity 칩의 native <details> root — 위로 열린다.
.axe-workspace-identity-menu__panelstable계정/entity 메뉴 표면(portal · in-flow 공용). in-flow 갈래는 위로 열리는 위치를 스스로 갖고, 제출 모드에서는 이 클래스를 <form> 이 받는다.
.axe-workspace-identity-menu__submenustable라디오 묶음(언어·표시)의 데스크톱 서브패널.
.axe-workspace-rail__linkstablenative <details> 갈래의 행. 목적지가 있으면 <a href>, 상태를 바꾸면 <button>, 제출 모드면 패널-폼의 submit 버튼.
.axe-workspace-rail__group-labelstablenative 패널 안의 group caption.
.axe-workspace-picker-dialogstable접힌 폭 팝업 — 트리거 바로 옆에 렌더되는 native <dialog>. 닫혀 있으면 display:none.
.axe-workspace-pickerstable팝업 패널.
.axe-workspace-picker__headerstable팝업 제목 + 닫기 버튼 행.
.axe-workspace-picker__closestable44px 팝업 닫기 버튼.
.axe-workspace-picker__liststable팝업의 유일한 세로 scroll 영역. 제출 모드에서는 이 클래스를 <form> 이 받는다.
.axe-dropdown-menu__separatorstable묶음 사이 구분선. 세 표면이 같은 자리에 갖는다 — identity 패널의 규칙은 workspace-shell.css(= core-data 프로파일 포함)가 재선언한다.
.axe-workspace-picker__labelstable팝업 헤더 caption 과 group caption(role="group" 의 이름 노드).
.axe-workspace-picker__itemstable44px 이상 팝업 행. 현재 항목은 aria-current + 체크 글리프.
비-React 소비 노트
React 를 쓰지 않는 소비자는 src/lib/contracts/rail-selectors/*.html 을 자기 템플릿 언어로 옮기고, contract.json 의 classes/attributes/outline 인벤토리를 자기 출력과 대조한다. 제출 모드에서 <form> 은 **패널 자체**다 — 행마다 <form> 을 두르면 행이 패널의 직계 자식이 아니게 되어 계약의 `> :is(a, button)` 규칙이 전부 빗나가고 행 기하가 무너진다. 픽스처는 컴포넌트 렌더 출력이라 손으로 고칠 수 없다 — `node scripts/gen-rail-fixtures.mjs --check` 가 드리프트를 막는다. 인쇄된 갈래는 JS 없이 서버가 낼 수 있는 것뿐이다(native <details> · native <dialog>). 드롭다운 갈래는 Radix portal 이라 서버렌더의 기준이 될 수 없다.
05 · Inclusive

접근성

키보드, ARIA, 구현 노트를 함께 검토합니다.

Keyboard
  • 칩에서 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 는 건드리지 않는다
ARIA

칩은 <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 는 이 속성을 쓰지 않는다.

Notes

팝업은 트리거 바로 옆에 렌더한다. 서랍(Sheet) 안에서 열릴 때 둘이 같은 DOM 이어야 서랍의 modal 장치가 팝업을 바깥으로 취급하지 않고, showModal() 이 기억한 트리거가 살아 있어 브라우저가 포커스를 돌려준다. 게스트(로그아웃) 상태는 예외가 아니라 계약된 상태다 — account={null} 하나로 칩·아바타·헤더·aria-label 이 함께 갈린다.

06 · Judgment

권장 · 지양

권장
  • 서비스 목록은 contracts/services.json 을 쓴다 — 다시 타이핑하는 순간 두 목록이 갈라진다.
  • 레일 하단은 entity 행 위에 계정 행 두 줄로 — 어느 조직에서, 내가 누구인가 한 문장으로 읽힌다.
  • 서버가 선택 상태를 갖는다면 action= 폼 모드를 쓴다. 클라이언트 상태를 흉내 내지 않는다.
지양
  • 칩·메뉴·팝업 마크업을 서비스 로컬에서 다시 조립하지 말 것 — 그게 다섯 벌로 갈라진 원인이다.
  • 전환할 것이 하나뿐이면 전환기를 쓰지 말 것. 계약의 .axe-workspace-entity--static 정적 칩이 그 자리다.
  • 접힌 폭에서 드롭다운을 그대로 쓰지 말 것 — 스크롤 중인 서랍 안에서 다시 스크롤되는 목록이 된다.
검색창을 열면 컴포넌트 인덱스를 불러옵니다.