본문으로 건너뛰기

PlTabs

여러 패널 중 하나를 보여 주는 묶음입니다. 인디케이터가 떠난 탭에서 고른 탭으로 미끄러집니다.

React
tsx
import { PlTab, PlTabPanel, PlTabs } from 'plass-ui';

<PlTabs defaultValue="account">
  <PlTab value="account">Account</PlTab>
  <PlTab value="billing">Billing</PlTab>

  <PlTabPanel value="account">Your name and your avatar.</PlTabPanel>
  <PlTabPanel value="billing">Cards and invoices.</PlTabPanel>
</PlTabs>;

탭과 패널은 형제로 쓰고, 컴포넌트가 알아서 둘을 갈라 놓습니다. 기억해야 할 <PlTabList>도 없고, 서브트리 배열 prop도 없습니다. 패널은 서브트리이고, 그것을 담을 쓸 만한 모양은 결국 children뿐입니다.

dart
import 'package:plass_ui/plass_ui.dart';

PlTabs<String>(
  value: tab,
  onChanged: (String next) => setState(() => tab = next),
  tabs: <PlTab<String>>[
    PlTab<String>(
      value: 'account',
      label: const Text('Account'),
      panel: const Text('Your name and your avatar.'),
    ),
    PlTab<String>(
      value: 'billing',
      label: const Text('Billing'),
      panel: const Text('Cards and invoices.'),
    ),
  ],
);

탭과 그 탭이 여는 패널은 하나의 설명입니다. 차이는 사실상 여기서 갈립니다. 맞춰 줘야 할 PlTabPanel도, 짝을 맞춰야 할 세 번째 값도 없고, 고르지 않은 패널은 아예 만들어지지 않습니다.

Props

Prop타입기본값설명
variant공통'solid' | 'glass' | 'ghost''glass'탭 **바**의 재질. solid는 홈에 맑은 판이 타고, glass는 가장자리의 선 위를 인디케이터가 달리고, ghost는 선이 없습니다
size공통'xs' | 'sm' | 'md' | 'lg' | 'xl''md'탭의 높이와 타입 스케일. PlButton과 같은 사다리
color공통'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'의미론적 색 역할. 임의 색상값은 받지 않습니다
density공통'default' | 'compact''default'여백만 바꿉니다. 높이와 글자 크기는 그대로
elevation공통0 | 1 | 2 | 30그림자 깊이. 0은 그림자 없음
orientation공통PlassResponsive<'horizontal' | 'vertical'>'horizontal'바가 놓이는 방향. vertical은 탭을 옆으로 세우고 방향키를 다른 축으로 옮깁니다
valuestring | number | null선택된 탭. onValueChange와 함께 controlled로 씁니다
defaultValuestring | number | nulluncontrolled일 때 처음 선택된 탭
onValueChange(value: string | number | null) => void선택이 바뀔 때 호출됩니다
activateOnFocusbooleanfalse방향키가 지나가는 탭을 곧바로 선택할지. 기본은 꺼짐 — 패널이 하나라도 fetch를 한다면 탭 네 개를 지나가는 동안 요청이 네 번 나갑니다
loopFocusbooleantrue마지막 탭에서 첫 탭으로 방향키가 넘어가는지
fullWidthbooleanfalse탭들이 바의 전체 너비를 균등하게 나눠 가집니다
wheelbooleantrue자리보다 탭이 많은 바 위에서 세로로 굴린 휠이 바를 따라 움직이게 합니다. 탭이 다 들어가는 바는 건드리지 않습니다
overscroll공통'auto' | 'contain''contain'탭이 다 떨어진 뒤의 휠을 바가 어떻게 할지. contain은 그대로 붙잡고, auto는 페이지로 넘깁니다
childrenReactNodePlTab과 PlTabPanel. 컴포넌트가 알아서 둘을 갈라 놓습니다
Prop타입기본값설명
tabs * List<PlTab<T>>탭들. children이 아니라 설명의 목록이고, 각자 자기 panel을 들고 있습니다
value * T?선택된 탭. null은 아무것도 고르지 않은 바입니다
onChangedValueChanged<T>?선택이 바뀔 때 호출됩니다
variant공통PlassVariantPlassVariant.glass탭 **바**의 재질. solid는 홈에 맑은 판이 타고, glass는 가장자리의 선 위를 인디케이터가 달리고, ghost는 선이 없습니다
size공통PlassSizePlassSize.md탭의 높이와 타입 스케일. PlButton과 같은 사다리
color공통PlassColorPlassColor.primary의미론적 색 역할. 임의 색상값은 받지 않습니다
density공통PlassDensityPlassDensity.standard여백만 바꿉니다. 높이와 글자 크기는 그대로
orientation공통PlassResponsive<PlassOrientation>PlassOrientation.horizontal바가 놓이는 방향. vertical은 탭을 옆으로 세우고 방향키를 다른 축으로 옮깁니다
fullWidthboolfalse탭들이 바의 전체 너비를 균등하게 나눠 가집니다
wheelbooltrue자리보다 탭이 많은 바 위에서 세로로 굴린 휠이 바를 따라 움직이게 합니다. 탭이 다 들어가는 바는 건드리지 않습니다
overscroll공통PlassOverscrollPlassOverscroll.contain탭이 다 떨어진 뒤의 휠을 바가 어떻게 할지. contain은 그대로 붙잡고, auto는 뒤에 있는 것에게 넘깁니다
semanticLabelString?바를 스크린 리더가 부를 이름. 눈에 보이는 자기 라벨이 없습니다
focusNodeFocusNode?바의 단 하나뿐인 focus stop을 바깥에서 몹니다
autofocusboolfalse트리에 들어가는 순간 focus를 가져갑니다

바와 패널이 받는 값은 string | number입니다.

바는 탭 값 타입에 대해 제네릭입니다(PlTabs<String>, PlTabs<Section>). 그래서 valueonChangeddynamic이 아니라 타입을 가지며, 패키지의 다른 컨트롤과 마찬가지로 controlled입니다. value는 nullable입니다. null은 아무것도 고르지 않은 바이고, 그 아래에는 패널도 없습니다.

PlTab

Prop타입기본값설명
value * string | number탭을 식별하고, 같은 값의 패널을 골라냅니다
startIconReactNode라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다
endIconReactNode라벨 뒤 — 개수, 상태 점
disabledbooleanfalse고를 수 없지만 목록에는 남습니다
childrenReactNode탭의 라벨
Prop타입기본값설명
value * T탭을 식별하고, 같은 값의 패널을 골라냅니다
labelWidget?탭의 라벨
startIconWidget?라벨 앞에 놓이는 내용. 라벨의 1.2배로 그려져 크기를 따라갑니다
endIconWidget?라벨 뒤 — 개수, 상태 점
disabledboolfalse고를 수 없지만 목록에는 남습니다
panelWidget?이 탭이 골라졌을 때 바 아래에 보이는 것. 고른 패널만 만들어집니다

PlTabPanel

Prop타입기본값설명
value * string | number어느 탭이 이 패널을 보여 주는지
keepMountedbooleanfalse숨겨진 동안에도 DOM에 남깁니다. 만드는 비용이 크거나 form 상태를 쥐고 있는 패널에
childrenReactNode패널의 내용

Flutter 패키지에는 아직 PlTabPanel가 없습니다.

variant, size, density, orientation은 감싸고 있는 PlTabs에서 내려받습니다. 이웃과 그중 무엇이든 달라질 수 있는 탭은 구멍 난 탭 바입니다.

탭은 위젯이 아니라 PlTab, 즉 설명입니다. segment가 그런 것과 같은 이유입니다. 바가 roving focus와 방향키, 그리고 탭 사이를 미끄러지는 인디케이터를 쥐고 있으니, 어느 탭이 골라졌고 각각이 어디 있는지를 알아야 합니다. panel이 함께 실려 있는 것은, 그러지 않으면 탭과 그 탭이 여는 것이 같은 사실을 두 번 적는 일이 되기 때문입니다.

variantsizedensityorientation도 없고, 있을 수도 없습니다. 이웃과 그중 무엇이든 달라지는 탭은 구멍 난 탭 바입니다.

라이브러리 전체에서 공유 축(variant size color density orientation)이 뜻하는 바는 prop 규칙에 있습니다.

Tabs와 segmented button 중 고르기

tabs는 내용 패널 전체를 바꿉니다. segmented button은 이미 화면에 있는 것을 걸러 냅니다. 여기서 solid 타일이 색 계열의 그러데이션이 아니라 맑은 유리판인 이유이기도 합니다. 그러데이션 타일은 segmented button의 것이고, 둘이 한 화면에 있으면 구분이 되어야 합니다.

Examples

variant

glass는 고전적인 바입니다. 가장자리의 선 위를 인디케이터가 달립니다. solid는 홈 안에서 판이 미끄러집니다. ghost는 그 선을 뺀 바로, 이미 자기 테두리가 붙은 PlCard 안의 탭에 씁니다.

React

orientation

vertical은 탭을 옆으로 세우고 패널을 그 옆에 놓으며, 방향키를 다른 축으로 옮깁니다. Base UI가 하는 일이고바가 직접 하는 일이고, 세로 탭 바에 닿을 수 있게 만드는 것이 바로 이것입니다.

반응형입니다. 그래서 한 집합이 폰에서는 이쪽으로, 노트북에서는 저쪽으로 갈 수 있습니다. 서버는 xs 항목을 렌더링하고 브라우저가 hydration에서 고칩니다.build에서 창 너비를 기준으로 풀리므로 첫 프레임부터 정확합니다. 브레이크포인트 참고.

React

fullWidth

React

size

탭은 컨트롤이므로 컨트롤 높이 사다리를 씁니다. md 탭과 md PlButton은 똑같이 40px이고, 그래서 탭 바가 툴바에서 버튼 옆에 놓여도 줄의 기준선이 유지됩니다.

React

자리보다 탭이 많은 바

자리보다 탭이 많은 바는 줄바꿈 대신 스크롤됩니다. 두 줄이 된 탭 바는 이미 바가 아니고, 인디케이터가 앉을 만한 자리도 없습니다.

그래서 스크롤 중이라는 것을 바가 직접 알려야 하는데, 스크롤바는 그 일을 하지 못합니다. macOS에서는 띠가 움직이는 동안에만 나타나는 오버레이여서, 독자가 더 볼 것이 있는지 판단하는 시간에는 보이지 않습니다. Windows에서는 라벨 줄 아래를 늘 차지하는 15픽셀짜리 가구입니다. 둘 다 감추고, 대신 아직 탭이 남아 있는 쪽 끝을 흐립니다. 남아 있는 쪽만 흐리므로 흐려진 끝은 언제나 더 있다는 뜻입니다.

이 흐림은 위에 덧칠하는 것이 아니라 픽셀을 덜어 내는 것이어서, 바가 무엇 위에 놓여 있든 옳습니다. 컴포넌트는 자기가 페이지 위인지 PlCard 위인지 색이 들어간 섹션 위인지 알 수 없고, 틀린 색으로 칠한 그러데이션은 신호가 없는 것보다 나쁩니다. 안의 탭이 focus ring을 그리고 있는 동안에는 흐림을 걷습니다. 탭에 focus가 가면 그 탭은 자기가 온 가장자리에 딱 붙게 스크롤되는데, 거기가 바로 흐림이 가장 진한 자리이기 때문입니다.

바가 넘치는지는 주어진 자리에 달렸으므로 이것은 선언이 아니라 측정입니다. 이를 위한 prop은 없습니다.

상태는 탭 리스트에 data-overflow로 실려 나갑니다. none, start, end, both이고 독자의 순서를 따릅니다. 페이지가 이를 읽어 스타일을 얹거나 테스트에서 단언할 수 있습니다.

React

스크롤되는 바는 에 반응합니다. 마우스에는 휠이 하나뿐이고 그 방향은 페이지 아래쪽, 바가 유일하게 달리지 않는 방향입니다. 그래서 넘치는 바 위에서 세로로 굴린 휠은 바를 따라 움직입니다. 탭이 더 있다는 것을 본 독자에게, 일부러 그리지 않는 스크롤바도 아니고 선택까지 바꿔 버리는 방향키도 아닌 길이 생깁니다. wheel로 끌 수 있고, 탭이 다 들어가는 바는 애초에 이 제스처를 가져가지 않습니다.

마지막 탭 다음의 이야기는 overscroll이 정합니다. 기본값 contain은 휠을 바에 붙잡아 두므로, 긴 탭 줄을 훑다가 페이지가 발밑에서 움직이는 일이 없습니다. auto는 끝에서 휠을 돌려주되, 조금 전까지 바를 움직이던 제스처만 붙잡습니다.

tsx
<PlTabs wheel={false} overscroll="auto">
dart
PlTabs<String>(wheel: false, overscroll: PlassOverscroll.auto, tabs: tabs);

Controlled

React

Accessibility

  • 탭 바를 버튼 줄이 아니라 탭 바로 만드는 것은 전부 Base UI의 것입니다. 바 전체가 tab stop 하나가 되는 roving focus, 바가 놓인 축의 방향키, HomeEnd, tab / tabpanel role, 그리고 둘을 잇는 aria-controls.
  • activateOnFocus는 기본이 꺼짐입니다. 자동 활성화는 모든 패널이 이미 페이지에 있을 때만 친절합니다. 패널 하나라도 fetch를 하는 순간, 탭 네 개를 지나가면 요청이 네 번 나갑니다.
  • 안에 focus 가능한 것이 없는 패널은 자기가 focus를 받으므로, 내용에 키보드로 닿을 수 있습니다.
  • 탭의 focus ring은 안쪽으로 그려집니다. solid 홈 안의 탭에 바깥쪽 ring을 그리면 이웃 위에 덧칠됩니다.
  • 인디케이터는 transform이 아니라 left, top, width, height를 애니메이션합니다. 빈 상자라서 글자가 담긴 것은 아무것도 움직이지 않습니다.
  • 자리보다 탭이 많은 바는 줄바꿈 대신 스크롤되고, 아직 탭이 남아 있는 쪽 끝을 흐립니다. 두 줄이 된 탭 바는 이미 바가 아니고, 인디케이터가 앉을 만한 자리도 없습니다.
  • 바 전체가 focus stop 하나입니다. 탭 하나만 tab 순서에 있고 나머지는 ExcludeFocus 안에 있습니다. 바를 버튼 줄이 아니라 바로 만드는 것이 이것입니다.
  • 가로 바에서는 , 세로 바에서는 가 선택을 옮기며, 양 끝에서 감기고 비활성 탭은 건너뜁니다. EnterSpace는 focus된 탭을 고릅니다.
  • 각 탭은 서로 배타적인 묶음의 하나로, 골라졌는지 아닌지와 함께 읽힙니다. 바 자체는 semanticLabel을 줄 수 있는 컨테이너입니다. 꼭 주세요. 바에는 눈에 보이는 이름이 없습니다.
  • focus가 움직이면 선택도 움직입니다. 고른 패널만 만들어지기 때문이고, focus와 내용이 어긋날 수 있는 바는 하나를 보여 주면서 다른 것을 읽는 바입니다. 패널이 비싸다면 탭이 아니라 build 바깥으로 그 일을 빼세요.
  • 탭의 focus ring은 안쪽으로 돕니다. solid 홈 안의 탭에 바깥쪽 ring을 그리면 이웃 위에 덧칠됩니다.
  • 인디케이터는 transform이 아니라 자기 상자(위치와 크기)를 애니메이션합니다. 빈 사각형이라서 글자가 담긴 것은 아무것도 움직이지 않습니다. OS에서 애니메이션을 끄면 바로 건너뜁니다.
  • 자리보다 탭이 많은 바는 줄바꿈 대신 스크롤되고, 아직 탭이 남아 있는 쪽 끝을 흐립니다. 예전에는 자기 탭들만큼 넓어서 상자를 넘쳤고 직접 감싸라는 안내가 붙어 있었는데, 그 방법은 통하지 않습니다. PlTabsSingleChildScrollView로 감싸면 바와 함께 패널까지 스크롤됩니다.

React 빌드와 다른 점

ReactFlutter이유
<PlTab><PlTabPanel> children각자 panel을 든 설명 목록인 tabs바가 roving focus와 방향키, 미끄러지는 인디케이터를 쥐고 있으니 어느 탭이 골라졌고 각각이 어디 있는지 알아야 합니다. 패널을 탭에 붙이면 값을 맞춰야 할 세 번째 자리가 사라집니다.
defaultValue / onValueChangevalue / onChangedFlutter의 컨트롤은 controlled이고, 콜백 이름도 Flutter의 것입니다.
string | number제네릭 TDart에는 제네릭이 있으니 관습이 아니라 타입 검사로 지켜집니다.
모든 패널을 렌더링하고 하나만 보임고른 패널만 만듦열려 있지 않은 탭은 비용이 0입니다. 대신 패널을 떠나면 그 상태도 사라지니, 상태는 바 위에서 쥐세요.
activateOnFocus패널이 선택에서 만들어지므로, focus가 움직이면 선택도 언제나 함께 움직입니다.
aria-labelsemanticLabelFlutter의 이름입니다.
tab / tabpanel role, aria-controls배타적으로 선택된 노드와, 패널 하나Flutter는 상태를 노드 자체에 적습니다. 가리킬 id가 없습니다.
className, style전달할 클래스 목록도 style 속성도 없습니다.

Released under the MIT License