Drilldown
한 화면 안에 쌓이는 뷰 스택입니다. 다이얼로그 안의 설정 → 계정 → 이메일 같은 흐름이 그 예입니다. Drilldown.Trigger는 같은 자리에 뷰를 밀어 넣고(push), Drilldown.Back은 그 뷰를 꺼내며(pop), 바깥의 오버레이는 열린 채로 있습니다. Drilldown은 Dialog, Drawer, BottomSheet, Popover 안에 넣거나 페이지에 바로 놓습니다.
이 페이지의 내용
Drilldown.Root는 요소를 렌더링하지 않으므로 오버레이의 헤더와 본문을 함께 감쌀 수 있습니다. Drilldown.Back은 제목 앞에 놓이며 첫 뷰에서는 아무것도 렌더링하지 않습니다. Drilldown.Label은 보이는 뷰의 label을 출력하므로 제목과 다이얼로그의 이름이 뷰를 따라갑니다. Drilldown.Viewport는 뷰를 담고, 각 Drilldown.View는 label로 이름이 붙는 그룹입니다.
<Dialog.Root open={open} onOpenChange={setOpen}>
<Drilldown.Root defaultValue={['settings']}>
<Dialog.Header>
<Drilldown.Back />
<Dialog.Title>
<Drilldown.Label />
</Dialog.Title>
<Dialog.Close />
</Dialog.Header>
<Dialog.Body>
<Drilldown.Viewport>
<Drilldown.View value="settings" label="Settings">
<Drilldown.Trigger value="account">Account</Drilldown.Trigger>
<Drilldown.Trigger value="notifications">Notifications</Drilldown.Trigger>
</Drilldown.View>
<Drilldown.View value="account" label="Account">…</Drilldown.View>
<Drilldown.View value="notifications" label="Notifications">…</Drilldown.View>
</Drilldown.Viewport>
</Dialog.Body>
</Drilldown.Root>
</Dialog.Root>
스택
값은 뷰 값의 스택으로, 첫 뷰가 맨 앞에, 보이는 뷰가 맨 뒤에 옵니다. Drilldown.Root는 value와 onValueChange, 또는 defaultValue를 받습니다. onValueChange는 다음 스택과 이유를 받습니다. 트리거에서는 push, Drilldown.Back에서는 back, Escape나 Android 뒤로 제스처에서는 escape, 제어하지 않는 스택이 처음으로 돌아갈 때는 reset입니다. 고정된 트리가 아니라 스택이므로 한 뷰에 두 곳에서 들어갈 수 있고, 제어하는 value로 여러 뷰를 한 번에 건너뛸 수 있습니다. 딥 링크라면 ['settings', 'account', 'email']처럼 넘깁니다.
뷰는 값이 스택에 있는 동안 마운트됩니다. 스택 아래쪽 뷰는 마운트된 채 숨겨지므로 돌아가도 폼 입력이 남아 있습니다. 스택에서 빠진 뷰는 슬라이드가 끝나면 언마운트됩니다. 제어하지 않는 스택은 바깥의 Dialog, Drawer, BottomSheet, Popover가 퇴장 모션까지 마치고 다 닫힐 때마다 defaultValue로 돌아가고, onValueChange는 이유 reset을 받습니다. 제어하는 스택은 둔 그대로 남고, 퇴장 중에 다시 열린 표면의 스택도 그대로 남습니다. 표면이 다 닫히기 전에 언마운트하거나 <Activity>로 숨긴 Drilldown은 reset을 받지 않고, 퇴장 중에 표면 자체를 언마운트한 경우에도 받지 않습니다. 퇴장 중에 <Activity>로 숨긴 표면은 숨겨져 있는 동안 reset을 보내지 않고, 다시 보이고 다 닫힐 때 보냅니다.
Drilldown.Trigger는 Button이고 Button의 props를 받으므로, primary 다음 버튼, chevron이 붙은 ghost 행, render를 통한 링크가 될 수 있습니다. 단계를 먼저 검증하는 것처럼 비동기 작업 뒤에 push하려면 value를 제어하고 작업이 끝났을 때 스택을 설정하세요. 그래도 Back은 포커스를 트리거로 돌려보냅니다. 트리거의 push로 칠 수 있는 것은 바로 다음 스택 변경뿐입니다. 그 변경이 그 뷰를 보여 주면 Back은 포커스를 트리거로 돌려보내므로, 거절한 push를 나중에 직접 하더라도 포커스는 트리거로 돌아갑니다. 그 전에 다른 뷰를 보여 주는 것 같은 다른 변경이 먼저 오면 push는 끝나고, Back은 뷰의 첫 탭 정지점에 포커스를 둡니다.
import { ChevronRight } from 'lucide-react';
<Drilldown.Trigger value="brand" variant="ghost">
Brand
<Button.Icon>
<ChevronRight />
</Button.Icon>
</Drilldown.Trigger>
Escape와 뒤로 제스처
기본값인 escape="back"에서는 Escape가 뷰를 하나 뒤로 가고, 첫 뷰에서만 오버레이를 닫습니다. Escape가 가장 안쪽 층부터 닫는 것과 같은 방식입니다. Popover 안에서는 팝오버가 닫히기 전에 Escape가 뒤로 가고, 다른 Drilldown의 뷰 안에 있는 Drilldown에서는 안쪽이 먼저 뒤로 갑니다. Android 뒤로 제스처와 버튼은 키 없이 닫기 요청(close request)으로 모달 <dialog>에 도착하고, Drilldown은 이것에도 같게 답하므로 깊은 스택이 한 번에 사라지지 않습니다. Chrome은 연이은 닫기 요청 중 일부만 페이지가 취소하게 하고 나머지에서는 다이얼로그를 닫습니다. 그런 요청에도 Drilldown은 뒤로 가고, 다이얼로그는 다시 열립니다. 오버레이 안에 열린 메뉴와 팝오버는 뷰 옆에 있든 뷰 안에 있든 Drilldown이 뒤로 가기 전에 Escape와 뒤로 제스처를 먼저 받고, 열린 툴팁은 Escape만 받습니다. 둘 다 포커스가 Drilldown을 감싼 다이얼로그나 팝오버 안에 있을 때, 또는 그 표면이 맨 앞에 있는 채로 포커스가 페이지로 빠졌을 때만 Drilldown에 갑니다. 그래서 Drilldown을 담은 Popover 밖에 포커스가 있으면 Escape와 뒤로 제스처는 팝오버를 닫습니다. dismissOnEscape={false}인 다이얼로그에서도 돌아갈 뷰가 있는 동안 Escape는 뒤로 갑니다. escape="close"는 Escape를 오버레이에 맡깁니다. 목록을 닫는 콤보박스처럼 뷰 안의 컨트롤이 이미 받은 Escape, IME 조합 중인 키, 그리고 닫힌 팝오버나 숨겨진 탭 패널 안처럼 화면에 없는 Drilldown으로는 뒤로 가지 않습니다.
BottomSheet 안에서는 시트가 스냅 위치를 유지하고 grabber는 여전히 시트를 끕니다. 가장자리를 밀어 뒤로 가는 동작은 없습니다.
모션
push된 뷰는 인라인 끝에서 미끄러져 들어오고, 아래 뷰는 인라인 시작 쪽으로 조금 밀리며 흐려집니다. 뒤로 갈 때는 반대로 움직입니다. 시작과 끝은 텍스트 방향을 따르므로 오른쪽에서 왼쪽으로 읽는 텍스트에서는 push된 뷰가 왼쪽에서 들어옵니다. 뷰포트는 뷰 사이에서 높이를 애니메이션하므로 내용에 맞춰 크기가 정해지는 다이얼로그가 튀지 않습니다. 높이는 모든 뷰의 슬라이드가 끝날 때까지 새 뷰의 높이로 유지되고, CSS로 슬라이드를 길게 바꿔도 마찬가지입니다. 화면의 높이로는 닿지 않는 스크롤 위치를 되돌리는 pop에서는 슬라이드가 끝날 때까지 두 뷰 중 더 높은 쪽의 높이를 따르다가 그 뒤에 뷰에 맞춰지므로, 되돌린 위치가 잘리지 않습니다. 슬라이드는 drilldown.motion.duration과 drilldown.motion.easing.enter, exit를 읽고, Todae 스타일시트가 없을 때처럼 이 값을 읽을 수 없으면 높이는 각 토큰의 기본값을 씁니다. 뷰가 움직이는 동안 뷰포트의 data-direction은 forward나 back이고, 움직이는 뷰에는 그 뷰가 움직이는 방향으로 data-entering이나 data-leaving이 붙습니다. 뷰는 CSS 전환(transition)으로 미끄러지므로, 슬라이드 도중에 push나 pop이 끼어들어도 뷰는 지금 있는 자리에서 이어서 움직이고, 뷰포트의 높이도 그런 pop이 아니면 화면의 높이에서 이어서 움직입니다. 나가는 뷰는 자신의 전환이 끝날 때까지 inert이므로 CSS로 전환 길이를 바꿔도 함께 맞춰집니다. 동작 줄이기(reduced motion)에서는 슬라이드가 없습니다.
접근성을 지키는 조합
Todae가 맡는 것: 각 Drilldown.View는 label로 이름이 붙는 role="group"이고, Drilldown.Label이 그 label을 오버레이 제목에 넣으므로 다이얼로그의 이름이 뷰를 따라갑니다. 기억해 둔 것이 없는 뷰가 보일 때, 즉 push되거나 제어하는 값으로 처음 보일 때 포커스는 뷰의 initialFocusRef가 포커스를 받을 수 있으면 그곳, 아니면 첫 탭 정지점, 그것도 없으면 뷰 자체로 갑니다. 전에 보였던 뷰로 pop하면 포커스는 위 뷰를 push한 트리거로 돌아가고, 그 트리거가 없거나 비활성이면 같은 순서로 initialFocusRef, 첫 탭 정지점, 뷰 자체로 갑니다. 포커스는 뷰 안이나 Back, 오버레이 요소 자체에 있었거나 어디에도 없었을 때만 움직이므로, 뷰 밖에서 제어하는 value를 설정하는 직접 만든 다음 버튼은 포커스를 유지합니다. 포커스는 스크롤 없이 움직입니다. push나 제어하는 값으로 앞으로 가면 오버레이의 스크롤 컨테이너가, 인라인이라면 페이지가 뷰포트의 맨 위를 지나 있던 경우 그 맨 위로 돌아갑니다. 보이던 중에 가려진 뷰는 스크롤 위치와 push한 트리거를 기억하고, 그 뷰로 pop하면 둘 다 돌아옵니다. 제어하는 값으로 건너뛴 뷰는 보인 적이 없으므로 돌려받을 것이 없습니다. 그 뷰로 pop하면 뷰포트의 맨 위에서 시작하고, 포커스는 새로 push된 뷰에서와 같은 곳으로 갑니다. 스택 아래쪽 뷰는 hidden이고, 미끄러져 나가는 뷰는 inert와 aria-hidden이므로 Tab도 스크린 리더도 그 뷰에 닿지 않습니다. Drilldown.Back의 이름은 StringsProvider의 backTo 문자열로 "Back to"와 아래 뷰의 label을 합친 것이고, 글리프는 GlyphsProvider의 back 글리프이며 오른쪽에서 왼쪽으로 읽는 텍스트에서는 좌우가 뒤집힙니다.
직접 제공할 것: 모든 뷰의 label. 제목으로 쓸 만큼 짧게 정하세요. 오버레이 제목 안의 Drilldown.Label, 또는 뷰에 따라 바뀌는 직접 만든 제목. 어디로 가는지 텍스트로 말하는 트리거. 아이콘만 있는 행에는 aria-label이 필요합니다.
주의할 점: value가 같은 두 뷰, 또는 같은 값이 두 번 들어간 스택. 트리거는 이미 스택에 있는 값을 push하지 않습니다. 스택 아래쪽에 있는 동안 내용이 바뀌는 뷰는 마운트된 채로 남으므로 그 안의 effect가 계속 실행됩니다. 트리거에서 시작하는 긴 작업은 뷰가 바로 미끄러져 들어오므로 새 뷰에서 진행 상황을 보여 주거나, value를 제어해 작업이 끝났을 때 push하세요. 서버에서 렌더링하는 페이지에 바로 놓을 때: Drilldown.Label과 Back의 이름은 뷰가 마운트되어야 정해지므로, 서버 HTML과 첫 레이아웃 effect 전까지는 비어 있습니다.
언제 쓰나요
언제: 한 오버레이나 패널 안에서 몇 단계의 설정, 필터, 상세 정보를 오가는 경우. 각 단계가 앞 단계를 좁히고 같은 길로 돌아옵니다. 설정 → 계정 → 이메일, 필터 → 브랜드 같은 흐름입니다.
쓰지 않을 때: 같은 대상의 형제 섹션을 하나씩 보여 줄 때 → Tabs. 자체 URL이 있는 다른 페이지로 갈 때 → 링크와 라우터. 동작 목록 → Menu. 단계 전체를 한눈에 봐야 하는 단계별 흐름 → 폼 옆의 Steps.
Props
| 이름 | 타입 | 설명 |
|---|---|---|
value | readonly string[] | 제어하는 뷰 값 스택. 첫 뷰가 맨 앞, 보이는 뷰가 맨 뒤입니다. onValueChange와 함께 전달합니다. |
defaultValue | readonly string[] | 비제어일 때의 초기 스택. 바깥의 Dialog, Drawer, BottomSheet, Popover가 다 닫힐 때마다 이 스택으로 돌아가고, onValueChange가 reason reset과 함께 호출됩니다. |
onValueChange | (value: readonly string[], reason: DrilldownChangeReason) => void | 다음 스택과 함께 호출됩니다. reason은 push(Drilldown.Trigger), back(Drilldown.Back), escape(Escape, 또는 모달 안에서 Android 뒤로 제스처), reset(오버레이가 다 닫히면서 비제어 스택이 defaultValue로 돌아감)입니다. |
escape | "back" | "close" | back: Escape가 뷰를 하나 뒤로 가고, 첫 뷰에서만 바깥 오버레이를 닫습니다. close: Escape를 오버레이에 맡깁니다. 기본값: "back" |
children | React.ReactNode | Drilldown.Viewport, Drilldown.Back, Drilldown.Label. 안쪽 어디에 두어도 됩니다. |
| 이름 | 타입 | 설명 |
|---|---|---|
children | React.ReactNode | Drilldown.View 요소. |
| 이름 | 타입 | 설명 |
|---|---|---|
value 필수 | string | Drilldown.Trigger가 push하고 스택이 담는 값. |
label 필수 | string | 뷰 그룹의 이름이자, 뷰가 보이는 동안의 Drilldown.Label, 위 뷰에서의 Drilldown.Back 이름에 쓰입니다. |
initialFocusRef | RefObject<HTMLElement | null> | 돌아갈 트리거가 없는 뷰가 보일 때, 즉 push되거나 제어하는 값으로 처음 보이거나, 위 뷰를 push한 트리거가 사라진 뒤 pop될 때 포커스가 갈 곳. 없거나 포커스를 받을 수 없으면 뷰의 첫 탭 정지점, 그것도 없으면 뷰 자체입니다. |
children | React.ReactNode | 뷰의 내용. |
| 이름 | 타입 | 설명 |
|---|---|---|
value 필수 | string | push할 Drilldown.View. 그 뷰가 pop되면 포커스가 이 트리거로 돌아옵니다. |
variant | ButtonVariant | 버튼 변형. 기본값: "primary" |
render | (props: ButtonRenderProps) => ReactElement | Button.Root처럼 트리거를 행이나 링크 같은 다른 요소로 그립니다. |
onClick | React.MouseEventHandler<HTMLElement> | push 전에 실행됩니다. 이 뷰에 머물려면 event.preventDefault()를 호출하세요. |
children | React.ReactNode | 트리거 내용. |
| 이름 | 타입 | 설명 |
|---|---|---|
aria-label | string | 접근 가능한 이름. 글자나 숫자가 두 개 이상인 텍스트가 있으면 그 텍스트가 버튼의 이름이 됩니다. 그렇지 않으면 StringsProvider의 backTo 문자열이 아래 뷰의 label과 함께 이름이 되고, 그 뷰에 label이 없으면 back 문자열이 이름이 됩니다. 기본값: "Back to {label}" when its text, not counting aria-hidden or SVG parts, has fewer than two letters or digits |
onClick | React.MouseEventHandler<HTMLButtonElement> | 뒤로 가기 전에 실행됩니다. 머물려면 event.preventDefault()를 호출하세요. |
children | React.ReactNode | 버튼 내용. 없으면 GlyphsProvider의 back 글리프, 또는 줄의 시작 쪽을 가리키는 Todae의 chevron을 보여 줍니다. |
| 이름 | 타입 | 설명 |
|---|