Toggle Button
packages/ui/src/toggle-button.tsx
누른 표시가 화면에 남는 아이콘 버튼이다. 동작은 Base UI의 Toggle을 기반으로 한다.
역할과 경계
ToggleButton은 Container와 아이콘, 선택 요소인 Label을 소유한다. 켜짐과 꺼짐 두 상태를 가지며 다시 눌러야 꺼진다.- 눌러서 실행하고 끝나는 자리는 button.md가 담당한다. 두 컴포넌트를 가르는 것은 누른 뒤에 버튼 자체에 남는 표시가 있는지 여부다.
- 목록을 걸러 낼 조건을 고르는 자리는 chip.md가 담당한다. Chip이 켜고 끄는 것은 화면 전체에 적용되는 조건이고, ToggleButton이 켜고 끄는 것은 그 버튼이 붙어 있는 대상 하나의 상태다.
- 설정 화면에서 기능을 켜고 끄는 자리는 switch.md가 담당한다. Switch는 Label과 짝을 이루어 Field 안에 놓이는 폼 컨트롤이고 ToggleButton은 대상 옆에 놓이는 아이콘 버튼이다.
- 항상 하나가 켜져 있고 끌 수 없는 위치 표시는 Tab이 담당한다. Tab은
role="tab"과 방향키 이동을 갖는 별도 컴포넌트이며 아직 시스템에 없다. - 높이와 모서리 값은 interaction.md의 「공통 Control Height」와 「공통 Control Radius」가 소유한다. 이 문서는 어느 단계를 제공하는지만 정한다.
- 아이콘의
Line과Fill선택 규칙은 icon.md가 소유한다.
Anatomy
- Container: 모든 내부 요소와 hover/pressed/focus 시각 상태를 감싼다.
- Icon: 필수 요소다.
- Count: 선택 요소다.
내부 요소의 고정 순서는 Icon → Count다. 아이콘은 children에 섞지 않고 Property로 받으며, 어느 변에 붙는지는 컴포넌트가 data-icon으로 직접 표시한다. 소비자가 그 표시를 매번 적지 않아도 여백 보정이 걸린다.
Button과 달리 아이콘이 붙는 변은 언제나 앞쪽 하나다. 켜고 끄는 대상은 하나이므로 그 뜻을 나타내는 아이콘도 하나이며, 뒤에 하나를 더 두면 무엇을 켜는 버튼인지 읽히지 않는다.
Properties
icon: 필수다.Linevariant를 넘긴다.activeIcon: Ghost에서만 받으며 그때는 필수다.icon과 같은 아이콘의Fill을 넘긴다. Tonal과 함께 넘기면 타입에서 걸린다.variant(Style):ghost | tonal, 기본값ghost. 둘은 꺼짐에서 같은 모습이고 켜졌을 때 무엇까지 바뀌는지로 갈린다.- Ghost: 켜져도 배경이 생기지 않고 아이콘이
Fill로 바뀌면서 색이 함께 바뀐다. 고정, 즐겨찾기와 찜이 여기에 해당한다. - Tonal: 켜지면 옅은 배경이 생겨 한 단계 더 강조한다. 아이콘은 타입을 바꾸지 않고 그대로 둔다. 레이어나 영역을 열어 둔 상태가 여기에 해당하며, 그 밖의 토글도 강조가 필요하면 쓸 수 있다.
- Ghost: 켜져도 배경이 생기지 않고 아이콘이
tone:brand | custom, 기본값brandcolor:tone이custom일 때만 받으며 값은favorite과like다. 다른 tone과 함께 넘기면 타입에서 걸린다.controlSize(Size):xsmall | small | regular | large | xlarge, 기본값regularcount: 아이콘 옆에 붙는 개수. 없으면 높이와 같은 너비를 가진 정사각형이 된다.0도 그린다.pressed,defaultPressed,onPressedChange: 켜짐 상태. Base UIToggle의 이름과 동작을 그대로 쓴다.- State: Default, Pressed, Hover, Focus visible
Button이 갖는 값 중 다섯을 두지 않는다. variant의 fill은 배경이 이미 tone 색을 다 쓰고 있어 켜짐을 표시할 자리가 남지 않고, outline은 실제 쓰임이 확인되지 않았다. tone의 danger는 「되돌릴 수 없는 액션」이라는 뜻을 이미 갖고 있는데 ToggleButton은 다시 눌러 되돌리는 컨트롤이라 뜻이 어긋난다. tone의 neutral도 두지 않는다. 꺼짐이 이미 중립이므로 켜짐까지 무채색이면 색이 상태를 말하지 못하고 아이콘 모양만 남는다. layout의 fill도 두지 않는다. 아이콘 하나가 늘어난 폭을 채울 이유가 없다.
아이콘을 몇 개 받는지
Style이 정한다. Ghost는 배경이 없어 아이콘의 색과 모양이 켜짐을 말해야 하므로 둘을 받고, Tonal은 배경이 이미 켜짐을 말하므로 하나만 받는다. Tonal에서도 아이콘을 바꾸면 같은 것을 두 번 말하게 되고, 아이콘이 자리를 가리키는 표시가 아니라 상태 표시로 읽힌다.
Ghost가 둘을 받는 것은 Remix Icon의 RiStarLine과 RiStarFill이 각각 다른 React 컴포넌트여서다. 아이콘 하나를 받아 컴포넌트가 스스로 Fill로 바꿔 그릴 수단이 없다.
둘을 동시에 그려 놓고 CSS로 한쪽을 감추지 않는다. 화면 낭독기가 지금 쓰이지 않는 아이콘까지 만나게 되기 때문이며, 그래서 상태를 render로 받아 한쪽만 그린다.
Tonal에 activeIcon을 넘기면 타입에서 걸린다. 받아 두고 그리지 않으면 넘겨도 아무 일이 없는 Property가 생긴다.
타입이 막는 것은 여기까지다. 어느 자리에 어느 Style을 쓰는지는 Guidelines가 갖고, 그중 강조를 위해 Tonal을 고르는 판단은 권장이므로 count와 Custom color는 두 Style이 함께 받는다.
Count
아이콘 옆에 두는 것은 자유 문구가 아니라 개수다. 이 버튼이 켜고 끄는 것은 그 대상 하나의 상태이고, 옆에 붙는 숫자는 같은 상태를 켠 사람이 몇인지를 말한다. 게시글의 하트 개수가 여기에 해당한다. 문구를 받게 두면 아이콘이 이미 말하고 있는 것을 글자로 한 번 더 적는 자리가 생긴다.
숫자를 움직이는 것은 이 컴포넌트가 아니라 소비자다. 실제 개수는 서버가 갖고 있으므로 onPressedChange에서 켜지면 하나 더하고 꺼지면 하나 빼서 count로 다시 넘긴다. 컴포넌트가 스스로 더하면 서버가 이미 나를 세어 둔 경우에 두 번 세게 된다.
0은 그린다. 아무도 누르지 않았다는 것과 개수를 세지 않는다는 것은 다른 뜻이며, 개수를 세지 않는 자리는 count를 넘기지 않아 정사각형이 된다.
숫자는 tabular-nums로 그린다. 자릿수가 바뀔 때 숫자마다 폭이 달라지면 버튼이 눌릴 때마다 옆의 요소가 밀린다.
Size
interaction.md의 일곱 단계 중 다섯을 제공한다. XSmall 28px, Small 32px, Regular 36px, Large 40px, XLarge 48px이며 Button과 같은 단계다. 두 컴포넌트가 도구 모음에 나란히 놓이므로 한쪽만 단계를 달리하면 같은 줄에서 높이가 어긋난다.
좌우 여백, 아이콘과 Label 사이의 간격, 글자 크기와 아이콘 크기도 Button과 같은 값을 쓴다. 그 값들의 근거는 button.md의 「Size」가 소유한다.
켜짐과 꺼짐
꺼짐은 Style과 tone에 관계없이 하나다. 꺼진 토글은 아직 아무것도 고르지 않은 자리이므로 배경도 tone 색도 갖지 않으며, 그래서 Tonal도 꺼짐에서는 Ghost와 같은 모습이다.
| Style | 꺼짐 배경 | 꺼짐 글자 | 켜짐 배경 | 켜짐 글자 | 켜짐 아이콘 |
|---|---|---|---|---|---|
| Ghost | 없음 | oe-foreground-tertiary |
없음 | 600 | Fill로 바꾼다 |
| Tonal | 없음 | oe-foreground-tertiary |
Light 100 / Dark 800 | Light 800 / Dark 200 | 그대로 둔다 |
variant는 만드는 사람이 자리에 맞게 고르는 축이므로 상태가 그것을 갈아치우지 않는다. Ghost를 고른 자리는 켜져도 계속 Ghost이고, Tonal을 고른 자리에서 켜짐이 만드는 배경은 그 Style이 원래 갖는 옅은 배경이다.
Tonal 켜짐의 배경은 Chip의 Active와 성격이 다르다. chip.md는 Chip의 Active를 brand alpha 배경과 경계로 정해 두었고, ToggleButton의 Tonal은 경계 없이 팔레트 100단계 배경만 쓴다. 그래서 나란히 놓아도 두 컴포넌트가 같아 보이지 않는다.
Ghost 켜짐의 글자가 Button의 Label 단계인 800이 아니라 600인 것은, 배경이 없어 글자와 아이콘만으로 켜짐을 말해야 하는데 800에는 유채색이 거의 남지 않기 때문이다.
Hover와 Pressed는 어느 Style에서나 배경을 한 단계씩 진하게 밟는다. Light는 100에서 200, 300으로 내려가고 Dark는 800에서 700, 600으로 올라가 방향이 서로 반대다. Button이 쓰는 것과 같은 사다리다.
Custom color
favorite의 노랑과 like의 빨강은 위 단계 규칙에 들어오지 않는다. Tailwind 팔레트도 seed에서 생성한 값도 아니고, 별과 하트가 그 색으로 굳어져 있어 브랜드를 바꿔도 함께 움직이면 안 되는 고정 색이기 때문이다.
Button의 엑셀 색과 같은 자리이며 toggle-button.tsx가 로컬 값으로 갖는다. 공용 semantic token으로 올리지 않았다. 밖에서 굳어진 색이 반복해서 필요해지면 그때 함께 다룬다.
관용색이 굳어지지 않은 토글은 기본값 brand를 쓴다. Custom은 목록에 있는 색만 고를 수 있으므로 새로운 관용색이 필요해지면 먼저 목록에 들인다. Custom 꺼짐은 다른 tone과 같은 중립이며, 고정 색은 켜짐에만 나온다.
Focus
두 Style 모두 보이는 경계가 없으므로 interaction.md의 유형 3을 쓴다. 2px 간격을 두고 바깥에 2px ring을 그려 배경색과 무관하게 focus 위치가 보이게 한다.
갖지 않는 상태
Disabled, Read only와 Invalid는 이 컴포넌트에 없다. 아직 정하지 않은 것이 아니라 성립하지 않는 상태다.
Disabled는 disabled Property도 두지 않는다. 켤 수 없는 자리에는 이 버튼을 그리지 않으며, 그려져 있다는 것 자체가 누를 수 있다는 뜻이다. Read only는 조작만 막는 자리가 없으므로 담당할 상태가 없고, Invalid는 검증 대상이 되는 값이 없다.
Guidelines
Studio 화면의 Guidelines와 소제목을 동일하게 유지한다.
누른 표시가 화면에 남는 자리에만 쓰기
누른 뒤에도 켜진 상태가 남아 다시 눌러야 꺼지는 자리에 쓴다. 즐겨찾기, 찜, 알림 받기와 고정이 여기에 해당한다.
눌러서 실행하고 끝나는 자리에는 Button을 쓴다. 저장, 제출, 내려받기처럼 누른 뒤 버튼에 남는 표시가 없으면 그쪽이다.
목록을 걸러 낼 조건을 고르는 자리에는 Chip을 쓴다. 켠 것이 값으로 남아 다른 컨트롤과 함께 하나의 조건을 만들면 그쪽이다. 설정 화면에서 기능을 켜고 끄는 자리에는 Switch를 쓰고, 항상 하나가 켜져 있고 끌 수 없는 위치 표시에는 Tab을 쓴다.
레이어나 영역을 여는 토글에는 Tonal 쓰기
켜 두는 것이 레이어나 영역을 열어 둔 상태라면 Tonal을 쓴다. 검색 영역을 여는 버튼이 여기에 해당한다. 열려 있다는 것은 이 버튼만이 아니라 화면의 다른 부분까지 바뀌었다는 뜻이므로 아이콘만으로 말하지 않고 배경으로 한 단계 더 강조한다.
이쪽은 지키는 규칙이다. 영역을 열어 두고 아이콘만 바꾸면 화면이 왜 달라졌는지 그 버튼에서 읽히지 않는다.
그 밖의 토글은 Ghost로 두되 강조가 약하면 Tonal 보기
켜 두는 것이 그 대상 자체에 남는 상태라면 Ghost로 시작한다. 항목을 위로 고정해 두는 것, 즐겨찾기에 넣어 두는 것, 찜해 두는 것이 여기에 해당한다. 상태는 그 항목에 붙어 있고 화면의 나머지는 그대로이므로 아이콘의 색과 모양이면 충분하다.
막는 규칙은 아니다. 실제로 배치해 보고 그 자리에서 강조가 약하다고 판단하면 같은 기능에도 Tonal을 쓴다. 배경이 있는 화면 위나 요소가 빽빽한 자리에서는 아이콘 하나가 묻히기 때문이다.
켜졌다고 Style이 바뀌지 않는다. Ghost는 켜져도 배경이 생기지 않고 아이콘의 색과 모양만 바뀐다. Tonal은 꺼짐에서 Ghost와 같은 모습이고 켜지면 옅은 배경이 함께 생긴다.
켜짐을 색과 모양으로 함께 말하기
Ghost에서는 icon에 Line, activeIcon에 같은 아이콘의 Fill을 넘긴다. 그러면 켜짐이 색과 모양을 함께 갖는다.
둘에 같은 아이콘을 넘기면 색만 남는다. 색만으로 갈리는 상태는 색을 구별하기 어려운 사람에게 전달되지 않고, 옅은 배경 위에서는 누구에게나 흐려진다.
Tonal에서는 아이콘이 하나이고 배경이 켜짐을 말한다. 배경은 색만이 아니라 면적이 생기고 사라지는 변화이므로 색을 구별하기 어려워도 전달된다.
즐겨찾기와 찜에는 Custom color 쓰기
관용색이 굳어진 아이콘은 Custom 색상을 쓴다. 별의 노랑과 하트의 빨강은 사용자가 이미 알고 있는 색이다. tone="custom"과 color로 그 색을 꺼내면 켜졌다는 것을 글자 없이도 읽을 수 있다.
그 밖의 토글은 기본값 brand를 쓴다. 고정처럼 관용색이 없는 토글과 영역을 여는 토글이 여기에 해당한다. 별과 하트는 Tonal로 올려도 그 관용색을 그대로 쓴다.
개수는 소비자가 움직이기
count는 그리는 숫자일 뿐이고 숫자를 움직이는 것은 이 컴포넌트가 아니다. onPressedChange에서 켜지면 하나 더하고 꺼지면 하나 빼서 count로 다시 넘긴다.
컴포넌트가 스스로 더하지 않는 이유는 실제 개수를 서버가 갖고 있기 때문이다. 서버가 이미 나를 세어 두었다면 두 번 세게 된다.
0은 그린다. 아무도 누르지 않았다는 것과 개수를 세지 않는다는 것은 다른 뜻이며, 세지 않는 자리는 count를 넘기지 않아 정사각형이 된다.
언제나 접근 가능한 이름 주기
aria-label로 그 버튼이 무엇을 켜고 끄는지 전달한다. count가 있어도 마찬가지다. 숫자는 몇 명인지만 말하고 무엇을 세고 있는지는 말해 주지 못한다.
이름은 켜짐과 꺼짐에서 같은 것을 쓴다. 「즐겨찾기」와 「즐겨찾기 해제」를 오가면 지금 상태와 누르면 일어날 일이 뒤섞인다. 상태는 aria-pressed가 이미 전달하고 있다.
한 대상에 하나만 두기
즐겨찾기와 찜과 저장은 셋 다 나중에 다시 보려고 담아 두는 동작이다. 한 대상에 함께 두면 무엇이 어디에 남는지 알 수 없다.
성격이 다른 토글이 함께 필요하면 아이콘으로 갈리는 것만으로 충분한지 먼저 본다. 갈리지 않으면 개수를 함께 두거나 자리를 나눈다.
현재 정의하지 않은 항목
- Loading 상태
- 큰 개수의 축약.
999+처럼 자릿수를 줄이는 규칙을 정하지 않았다 - 켜짐과 꺼짐 사이의 전환 애니메이션
- 여러 ToggleButton을 하나로 묶는 Toggle group
- Tooltip 정책. Button의 아이콘 전용 버튼과 함께 정한다