Docs

docs/system/components/bottom-navigation.md

Bottom Navigation

packages/ui/src/bottom-navigation.tsx

모바일 화면의 맨 아래 한 줄이다. 지금 어느 화면에 있는지 알리고 다른 최상위 화면으로 옮긴다.

역할과 경계

  • BottomNavigation은 Container와 항목 목록을 소유한다. 각 Item은 Icon과 Label을 한 덩어리로 가지며 Notification Badge만 선택이다. 한 화면에 하나만 둔다.
  • 모바일 화면에서만 쓴다. 데스크톱 화면에는 쓰지 않는다.
  • 제품 전체를 가르는 최상위 화면으로만 옮긴다. 한 화면 안에서 목록을 거르거나 구간을 나누는 자리에는 쓰지 않는다. 그 자리는 Tab이 담당하며 아직 시스템에 없다.
  • 화면 위쪽 한 줄은 app-header.md가 담당한다.
  • 항목에 붙는 알림 표시는 notification-badge.md가 소유한다. 이 컴포넌트는 붙는 위치만 정한다.
  • 아이콘의 LineFill 선택 규칙은 icon.md가 소유한다.

Anatomy

  1. Container: 모든 항목을 감싸며 높이와 배경, 위쪽 경계선과 safe area 여백을 소유한다.
  2. Item: 항목 하나다. Icon과 Label을 함께 가진다.
  3. Icon: 필수 요소다. 언제나 Fill이다.
  4. Label: 선택 요소다. Icon 아래 한 줄이며 기본은 감춘 상태다. 값 자체는 언제나 받는다.
  5. Notification Badge: 선택 요소다. Icon의 오른쪽 위 모서리에 붙는다. 공용 notification-badge.md를 그대로 쓴다.

Properties

  • items: 필수다. 항목 셋부터 다섯까지다. 여섯을 넘기면 컴포넌트가 오류를 낸다.
  • value: 필수다. 지금 선택된 항목의 value다.
  • onValueChange: 항목을 눌렀을 때 그 value로 부른다.
  • items[].value: 항목을 가리키는 값이다.
  • items[].label: Icon 아래 한 줄이다.
  • variant (Style): bar | floating, 기본값 bar. bar는 화면 바닥에 붙어 폭을 다 쓰고 위쪽에 경계선을 둔다. 그 선은 본문과 줄을 가르기만 하면 되므로 oe-border-secondary보다 한 단계 옅다. floating은 좌우에 12px 여백을 두고 떠서 알약으로 서며, 경계선 없이 그림자만으로 본문과 갈린다. 화면의 기본 여백인 16px보다 한 단계 좁은 이유는 끝이 둥글어서 같은 값을 주면 본문보다 더 물러선 것으로 보이기 때문이다.
  • showLabels: false | true, 기본값 false. 아이콘 아래에 글자를 함께 보인다. 꺼도 items[].label은 그대로 받는다. 글자만 감추고 항목의 이름은 남기며, 지우면 화면 낭독기가 다섯 항목을 모두 「버튼」으로만 읽는다.
  • items[].icon: Fill variant를 넘긴다.
  • items[].badge: NotificationBadge의 Property를 그대로 받는다. classNamerender는 받지 않는다. 넘기지 않으면 표시가 붙지 않는다.
  • State: Selected, Unselected

항목 목록을 받는 이유

자리를 열어 두지 않고 목록을 받는다. app-header.md가 좌우를 ReactNode 자리로 여는 것과 다르다.

세 가지 때문이다. 항목의 폭이 개수에 따라 균등하게 나뉘어야 하고, 선택된 항목이 언제나 하나여야 하며, Icon과 Label과 선택 상태가 한 덩어리로 움직인다. 자리로 열어 두면 소비자가 각 항목에 선택 여부를 직접 넘기게 되어 둘이 동시에 켜지는 상태가 만들어진다.

선택은 컴포넌트가 스스로 바꾸지 않는다. 어느 화면에 있는지는 라우팅이 소유하므로 value를 넘긴 쪽이 다음 값을 정한다. 눌러도 onValueChange만 부르고 표시는 그대로다.

항목이 다섯을 넘으면 오류를 낸다. 조용히 잘라 내면 넘긴 항목이 화면에서 사라진 이유를 알 수 없다.

크기와 레이아웃

항목 영역의 높이는 라벨이 정한다. bar에서 showLabels가 꺼져 있으면 56px이고 켜면 68px이다. 56px은 아이콘 24px을 사방 16px이 감싸는 높이다. 라벨이 있으면 아이콘과 간격 8px, 라벨 16px이 48px을 쓰고 위아래로 10px씩 남아, 선택 표시와 위쪽 선 사이에 4px이 남는다. 두 값 사이는 200ms에 걸쳐 바뀐다. 컴포넌트가 실제로 차지하는 높이는 여기에 하단 여백을 더한 값이므로 라벨을 감췄을 때 56px + max(env(safe-area-inset-bottom), 16px)이다. 안전 영역이 없는 기기에서 72px이고 홈 인디케이터가 있는 기기에서 90px이다.

  • Icon: 24px
  • Label: 13px에 16px 행간, font-medium. 값은 text-oe-label token이 소유한다
  • Icon과 Label 사이: bar에서 8px, floating에서 4px. bar는 선택 표시가 아이콘보다 6px 아래까지 내려오므로 그보다 넓어야 표시가 라벨에 닿지 않는다
  • 하단 여백은 높이 바깥에 max(env(safe-area-inset-bottom), 16px)만큼 더한다.
    • 높이 안에 넣지 않는 이유는 인디케이터가 있는 기기에서 항목이 그만큼 눌리기 때문이다.
    • 최소 16px을 함께 보장하는 이유는 홈 인디케이터가 없는 기기에서 safe-area-inset-bottom이 0이라 항목이 화면 맨 아래에 붙기 때문이다. 그 자리는 손가락이 닿기 어렵고 안드로이드의 제스처 핸들과도 겹친다.

항목은 내용 폭이 아니라 같은 몫을 나눠 갖는다. 내용 폭을 따르면 라벨이 긴 항목만 넓어져 누르는 자리가 항목마다 달라진다.

목록은 주어진 폭을 그대로 쓴다. 컴포넌트가 자기 폭 상한을 들고 있지 않는다. 상한을 두면 뷰포트를 넓혔을 때 바탕만 넓어지고 항목은 가운데 모여 서서, 폭을 정한 쪽의 결정이 화면에 반영되지 않는다. 항목이 지나치게 벌어진다면 그것은 뷰포트가 넓은 것이므로 뷰포트에서 다루고, 그 화면만의 사정이면 페이지 단위로 조정한다. 어느 폭부터 어떻게 다룰지가 정해지면 이 문서가 아니라 상위 규칙이 갖는다.

Label은 한 줄에서 말줄임한다. 두 줄로 넘어가면 그 항목만 높아져 목록의 기준선이 어긋난다.

floating의 움직임

떠 있는 형태는 셋을 함께 갖는다. 값은 bottom-navigation.tsx가 소유한다.

  • 선택 표시는 하나가 자리를 옮긴다. 항목마다 배경을 켜고 끄지 않는다. 표시 하나가 300ms 동안 다음 자리로 미끄러지므로 어디에서 어디로 갔는지가 움직임 자체로 읽힌다. 표시는 줄의 사방에서 8px씩 물러선다. 위아래와 좌우가 같은 값이어야 표시가 알약 안에서 한가운데 놓인 것으로 보인다.
  • 라벨을 켜면 줄이 높아진다. 줄 높이는 선택 표시가 필요한 크기에 사방 여백을 더한 값이다. showLabels를 켜면 표시가 56px이라 72px, 끄면 48px이라 64px이며 200ms에 걸쳐 바뀐다. 글자가 빠진 만큼 높이가 남으면 아이콘만 있는 알약이 속 빈 상자로 보인다.
  • 누르는 동안 아이콘이 작아진다. 누르면 90%로 줄었다가 손을 떼면 돌아오고, 선택된 아이콘은 105%로 조금 커진 채 머문다. 눌린 것이 손에서 떨어지기 전에 화면에서 먼저 읽힌다.

움직임을 줄이도록 설정한 기기에서는 선택 표시가 전환 없이 곧바로 자리를 잡는다.

선택 표현

아이콘은 선택 여부와 무관하게 같은 Fill 하나를 그리고 색만 바뀐다.

  • 선택된 항목: Icon과 Label이 함께 oe-foreground-primary다. Brand를 쓰지 않는다. Bottom Navigation이 알리는 것은 브랜드가 아니라 지금 어느 화면에 있는지이며, 그 자리에 유채색을 두면 다섯 항목 중 하나만 색을 갖는 강조로 읽힌다. 이 token은 Light에서 neutral-950, Dark에서 neutral-50이라 두 테마 모두 나머지 항목과 가장 멀다.
  • floating의 선택 표시 배경: oe-background-secondary다. 여기에도 Brand를 쓰지 않는다. 형태가 바뀌어도 Bottom Navigation이 알리는 것은 지금 어느 화면에 있는지이며, 유채색 배경은 다섯 항목 중 하나만 강조된 것으로 읽힌다.
  • 선택되지 않은 항목: Icon과 Label이 함께 oe-foreground-subtle이다. 둘을 갈라 놓으면 아이콘이 먼저 사라지면서 항목이 무엇인지 알아보기 어려워진다. subtle은 위계 줄의 네 번째 칸이 아니라 그보다 약한 단계가 국소적으로 필요한 자리의 이름이며 정의는 tokens.md가 소유한다.

항목이 나란히 서는 자리에서 시각적 위계를 잡기 위해서다. Line과 섞으면 선택되지 않은 항목끼리도 시각 무게가 달라 보인다. 근거는 icon.md가 소유한다.

그래서 이 컴포넌트는 toggle-button.md와 달리 아이콘을 하나만 받는다.

Focus

경계가 없는 요소이므로 interaction.md의 유형 3을 쓰되, 항목이 서로 붙어 있어 바깥으로 나간 ring이 이웃을 침범하므로 간격 없이 안쪽으로 그린다.

갖지 않는 것

Disabled를 제공하지 않는다. 누를 수 없는 탭은 Bottom Navigation에 그리지 않는다. Property를 남겨 두면 시각 표현이 없는 상태를 소비자가 넘길 수 있게 된다.

가운데 강조 항목을 두지 않는다. 다섯 항목이 같은 무게로 선다.

스크롤에 따라 숨었다 나타나는 동작을 제공하지 않는다. 지금 어느 화면에 있는지 알려 주는 자리이므로 사라지지 않는다.

Guidelines

모바일 화면의 맨 아래에만 쓰기

  • 모바일 화면의 맨 아랫줄에 쓴다. 데스크톱 화면에는 쓰지 않는다.
  • 한 화면에 하나만 둔다.

화면을 가르는 자리에만 쓰기

  • 제품 전체를 가르는 최상위 화면으로만 옮긴다.
  • 한 화면 안에서 목록을 거르거나 구간을 나누는 데 쓰지 않는다. 위치 표시가 아니라 조작 도구로 읽힌다.
  • 한 화면 안을 나누는 것은 Tab이 맡는다. 아직 시스템에 없으므로 필요해지면 그때 만든다.

화면마다 항목을 바꾸지 않기

  • 모든 최상위 화면에 같은 항목을 같은 순서로 둔다.
  • 항목이 화면마다 달라지면 같은 자리를 눌러도 다른 곳으로 가게 되어, 하단 줄이 위치 표시가 아니라 화면마다 다른 메뉴가 된다.
  • 그 화면에서만 필요한 동작은 AppHeader의 오른쪽 자리나 본문에 둔다.

항목 정하기

  • 항목은 셋부터 다섯까지 둔다. 둘이면 화면을 가르는 것이 아니라 값을 고르는 것이므로 다른 컴포넌트를 쓴다.
  • Label은 국문 다섯 자, 영문 열 자 안에서 끝낸다. 넘치면 한 줄에서 잘려 무엇인지 읽히지 않는다.
  • 첫 항목은 홈처럼 돌아오는 자리로 둔다.
  • 375px 화면에서 다섯 항목일 때 Label이 잘리는지 확인한다.

알림 표시 쓰기

  • 동시에 세 항목 미만에만 붙인다. 여러 항목이 함께 알리면 어디를 먼저 봐야 하는지 알 수 없다.
  • 개수를 세는 것이 뜻이 있을 때만 count를 넘긴다. 처리할 것이 있다는 것만 말하면 점으로 둔다.

현재 정의하지 않은 항목

  • 항목을 길게 눌렀을 때의 동작.
  • 선택된 항목을 다시 눌렀을 때 목록의 맨 위로 돌아가는 동작.