PlBottomNavigation
창의 아래 가장자리에 붙어 있는 목적지 한 줄입니다. 진짜 링크나 버튼으로 된 <nav>이지, 탭 목록이 아닙니다. 한 페이지의 어느 패널을 보여 주는가가 아니라 페이지 자체를 바꾸기 때문입니다.
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
<PlBottomNavigation value={where} onValueChange={setWhere} label="Main">
<PlBottomNavigationItem value="home" icon={<HomeIcon />} href="/">
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchIcon />} href="/search">
Search
</PlBottomNavigationItem>
</PlBottomNavigation>;import 'package:plass_ui/plass_ui.dart';
PlBottomNavigation<String>(
value: where,
onChanged: (String next) => setState(() => where = next),
label: 'Main',
items: const <PlBottomNavigationItem<String>>[
PlBottomNavigationItem<String>(value: 'home', label: 'Home', icon: HomeIcon()),
PlBottomNavigationItem<String>(value: 'search', label: 'Search', icon: SearchIcon()),
],
);Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'glass' | 'ghost' | 'glass' | 시트의 재질. 절대 물들지 않습니다 — 색 가족을 나르는 것은 현재 항목입니다 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 줄의 최소 높이, 글리프의 크기, 그 아래 이름의 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | 드롭 섀도 깊이. 0이 기본이고 평평합니다 — 이 바는 창 가장자리에 붙어 있습니다 |
| value | string | number | null | — | 독자가 있는 목적지. onValueChange와 함께 controlled로 씁니다 |
| defaultValue | string | number | null | null | uncontrolled일 때 시작 목적지 |
| onValueChange | (value: string | number) => void | — | 고른 목적지로 호출됩니다 |
| position | 'static' | 'sticky' | 'fixed' | 'fixed' | 페이지 스크롤 안에서 바가 놓이는 방식 |
| labels | 'all' | 'selected' | 'none' | 'all' | 어떤 이름을 그릴지. 그리지 않는 이름도 문서에는 남습니다 |
| divider | boolean | true | 위쪽 가장자리에 얇은 선을 그립니다 |
| safeArea | boolean | true | 홈 인디케이터에서 줄을 떼어 놓습니다. 시트는 화면 바닥까지 그대로 닿습니다 |
| disabled | boolean | false | 모든 목적지가 반응하지 않습니다 |
| label | string | — | 바가 안내되는 이름 |
| render | RenderProp | — | <nav> 대신 다른 요소로 렌더링합니다 |
| children | ReactNode | — | PlBottomNavigationItem들 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| items * | List<PlBottomNavigationItem<T>> | — | 목적지들. children이 아니라 설명의 목록입니다 — 바가 무엇이 현재이고 몇 개인지를 알아야 합니다 |
| value * | T? | — | 독자가 있는 목적지. onValueChange와 함께 controlled로 씁니다 |
| onChanged | ValueChanged<T>? | — | 고른 목적지로 호출됩니다. 주지 않으면 바가 그대로 굳습니다 |
| variant공통 | PlassVariant | PlassVariant.glass | 시트의 재질. 절대 물들지 않습니다 — 색 가족을 나르는 것은 현재 항목입니다 |
| size공통 | PlassSize | PlassSize.md | 줄의 최소 높이, 글리프의 크기, 그 아래 이름의 타입 스케일 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | int | 0 | 드롭 섀도 깊이. 0이 기본이고 평평합니다 — 이 바는 창 가장자리에 붙어 있습니다 |
| labels | PlBottomNavigationLabels | PlBottomNavigationLabels.all | 어떤 이름을 그릴지. 그리지 않는 이름도 문서에는 남습니다 |
| divider | bool | true | 위쪽 가장자리에 얇은 선을 그립니다 |
| safeArea | bool | true | 홈 인디케이터에서 줄을 떼어 놓습니다. 시트는 화면 바닥까지 그대로 닿습니다 |
| disabled | bool | false | 모든 목적지가 반응하지 않습니다 |
| label | String? | — | 바가 안내되는 이름 |
PlBottomNavigationItem
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | string | number | — | 목적지를 식별합니다. onValueChange가 보고하는 값입니다 |
| icon | ReactNode | — | 이름 위의 글리프 |
| href | string | — | 항목을 버튼이 아니라 진짜 링크로 렌더링합니다 |
| disabled | boolean | false | 쓸 수 없지만 집합에는 남습니다 |
| children | ReactNode | — | 목적지의 이름. labels가 그리지 않아도 읽힙니다 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | T | — | 목적지를 식별합니다. onValueChange가 보고하는 값입니다 |
| label * | String | — | 목적지의 이름. 위젯이 아니라 String이고 필수입니다 — 그려지는 이름이자 언제나 읽히는 이름입니다 |
| icon | Widget? | — | 이름 위의 글리프 |
| disabled | bool | false | 쓸 수 없지만 집합에는 남습니다 |
바에는 네이티브 <nav> 속성이, 항목에는 네이티브 <button> 속성이 그대로 전달됩니다. color는 여기서 Plass의 prop이라, onChange는 바가 onValueChange로 쓰기 때문에 제외됩니다.
바는 목적지 타입에 대해 제네릭입니다. 그래서 value와 onChanged가 dynamic이 아니라 타입 검사를 받고, controlled입니다. 값을 받고 그것을 대체해야 할 값을 보고합니다. 이 패키지의 다른 모든 입력이 그렇게 동작합니다.
항목은 위젯이 아니라 설명입니다. PlAccordion과 PlTable이 이미 쓰는 관용구이고, 바가 어느 목적지가 현재이고 몇 개가 있는지를 알아야 하는데 Widget은 불투명하기 때문입니다.
항목은 자기 size도 color도 variant도 갖지 않습니다. 셋 다 집합의 것입니다. 한 번 정하면 모든 목적지에 같은 뜻이 되는 유일한 자리이고, PlTabs와 PlSegmentedButton이 쓰는 것과 같은 방식입니다. 라이브러리 전체에서 공유 축이 뜻하는 바는 prop 규칙에 있습니다.
Examples
링크와 landmark
탭 목록은 키보드 사용자에게 집합 전체에 대한 탭 정지 하나와 그 안의 화살표 키를 빚지고, 스크린 리더에게는 탭마다 패널 하나를 빚집니다. 하단 내비게이션은 그중 어느 것도 하지 않습니다. 페이지를 바꿉니다. 동작 없이 role만 선언하는 것은 아예 주장하지 않는 것보다 키보드 사용자에게 나쁩니다.
대신 선언하는 것은 aria-current이고, 그것이 정직한 진술입니다. 지금 있는 목적지는 여기입니다. aria-pressed는 절대 아닙니다. 그것은 토글로 만들어 버립니다.
놓이는 자리
position의 기본값은 레이아웃 컴포넌트가 쓸 static이 아니라 fixed입니다. 하단 내비게이션이란 바로 그것이기 때문입니다. 페이지가 무엇을 하든 창의 아래 가장자리에 붙들려 있는 것. sticky는 스크롤되는 패널 안에서의 같은 것이고, static은 흐름 안에 놓습니다. 이 페이지의 미리보기가 static을 쓰는 이유는, fixed 바는 페이지를 떠나 브라우저 창에 붙어 버리기 때문입니다.
창의 가장자리를 가로지르는 바에는 모서리 뒤에 아무것도 없습니다. 그래서 흐름 안에 있는 것만이 모서리가 붙은 시트입니다.
position은 없습니다. Flutter 화면에는 위젯이 빠져나올 페이지 스크롤이 없기 때문입니다. 바는 앱의 스캐폴드가 하단 슬롯이라 부르는 자리에, 또는 Stack의 바닥에 놓입니다. 어느 쪽이든 정하는 것은 바가 아니라 앱입니다.
모서리는 React 빌드와 같은 이유로 각져 있습니다. 화면 가장자리를 가로지르는 바에는 깎을 모서리가 뒤에 없습니다.
labels
all은 모든 목적지에 이름을 붙이고, 앱을 처음 쓰는 사람에게 통하는 유일한 설정입니다. selected는 현재 것에만 이름을 붙입니다. none은 이름을 하나도 그리지 않습니다.
어느 설정에서도 바의 높이는 그대로입니다. 이름이 붙은 항목이 언제나 가장 높기 때문이고, 바뀌는 것은 줄에서 글자가 차지하는 비율입니다.
그리지 않는 것이 말하지 않는 것은 아닙니다. 글리프 하나만으로는 접근 가능한 이름이 없으므로, 그리지 않는 이름은 픽셀과 함께 버리는 대신 잘라낸 상자 안에 문서로 남겨 둡니다.
import { PlBottomNavigation, PlBottomNavigationItem, PlTypography } from 'plass-ui';
import { AccountGlyph, HomeGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationLabels() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['all', 'selected', 'none'] as const).map((labels) => (
<div key={labels} className="flex flex-col gap-1">
<PlTypography level="caption">labels={labels}</PlTypography>
<PlBottomNavigation
position="static"
labels={labels}
defaultValue="search"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="account" icon={<AccountGlyph />}>
Account
</PlBottomNavigationItem>
</PlBottomNavigation>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationLabels extends StatelessWidget {
const BottomNavigationLabels({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlBottomNavigationLabels labels in PlBottomNavigationLabels.values)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: <Widget>[
PlTypography('labels: ${labels.name}', level: PlTypographyLevel.caption),
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'search',
labels: labels,
safeArea: false,
onChanged: (String _) {},
),
],
),
],
),
);
}
}variant와 color
시트는 PlCard에서와 마찬가지로 절대 물들지 않습니다. 바는 각자의 아이콘을 들고 오는 목적지들을 담고 있고, 그 아래 판에 색을 넣으면 모든 아이콘이 그것을 기준으로 고르지 않은 배경 위에 놓입니다. 색 가족을 나르는 것은 현재인 항목 하나입니다.
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationVariants() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['glass', 'solid', 'ghost'] as const).map((variant) => (
<PlBottomNavigation
key={variant}
variant={variant}
position="static"
defaultValue="home"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlBottomNavigationItem>
</PlBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationVariants extends StatelessWidget {
const BottomNavigationVariants({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
variant: variant,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}divider, safeArea, elevation
divider는 위쪽 가장자리에 얇은 선을 그리고 기본으로 켜져 있습니다. 스크롤되는 페이지 위에 고정된 바는 언제나 그 아래로 내용이 지나가고 있고, 가장자리를 표시하는 것이 없는 반투명 시트는 그 내용의 일부처럼 읽힙니다.
safeArea는 휴대폰의 홈 인디케이터에서 줄을 떼어 놓습니다. 시트는 여전히 화면 바닥까지 닿습니다(움직이는 것은 항목뿐입니다). 인디케이터 위에서 멈춘 바는 유리 아래로 페이지가 비치는 띠를 남기기 때문입니다.
elevation은 0이고, 평평한 것이 맞습니다. 이 바는 창 한가운데 위에 떠 있는 것이 아니라 창 가장자리에 붙어 있고, 내용과 갈라 주는 것은 divider입니다. 페이지 위에 떠 있는 바는 다른 물건이고, 그것은 PlFloatingBottomNavigation입니다.
size
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { HomeGlyph, SavedGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(['sm', 'md', 'lg'] as const).map((size) => (
<PlBottomNavigation
key={size}
size={size}
position="static"
defaultValue="home"
safeArea={false}
>
<PlBottomNavigationItem value="home" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="saved" icon={<SavedGlyph />}>
Saved
</PlBottomNavigationItem>
</PlBottomNavigation>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/bottom_navigation/destinations.dart';
class BottomNavigationSizes extends StatelessWidget {
const BottomNavigationSizes({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final PlassSize size in <PlassSize>[PlassSize.sm, PlassSize.md, PlassSize.lg])
PlBottomNavigation<String>(
items: destinations.take(3).toList(),
value: 'home',
size: size,
safeArea: false,
onChanged: (String _) {},
),
],
),
);
}
}href
href가 있으면 항목은 진짜 <a>입니다. 길게 눌렀을 때 "새 탭에서 열기"가 뜨게 하고 목적지를 상태 표시줄에 보여 주는 것이 그것인데, router.push를 부르는 <button>은 둘 다 하지 못합니다. href가 없으면 <button>입니다. 클릭 핸들러를 단 <div>는 키보드에 보이지 않기 때문입니다.
비활성화된 링크는 aria-disabled 뒤에 살아 있는 링크를 남기는 대신 href를 잃습니다. disabled는 <a>가 될 수 있는 상태가 아니기 때문입니다.
import { PlBottomNavigation, PlBottomNavigationItem } from 'plass-ui';
import { AccountGlyph, HomeGlyph, SearchGlyph } from './glyphs';
export default function BottomNavigationLinks() {
return (
<div className="w-full max-w-sm">
<PlBottomNavigation position="static" defaultValue="home" safeArea={false}>
<PlBottomNavigationItem value="home" href="#bottom-navigation" icon={<HomeGlyph />}>
Home
</PlBottomNavigationItem>
<PlBottomNavigationItem value="search" href="#bottom-navigation" icon={<SearchGlyph />}>
Search
</PlBottomNavigationItem>
<PlBottomNavigationItem value="account" disabled icon={<AccountGlyph />}>
Account
</PlBottomNavigationItem>
</PlBottomNavigation>
</div>
);
}Accessibility
- 스크린 리더가 건너뛰거나 지나칠 수 있는 이름 붙은 그룹입니다: <nav> 랜드마크시맨틱 컨테이너.
labels가 무엇이든 모든 항목에 접근 가능한 이름이 있습니다. 그리지 않는 것이 말하지 않는 것은 아닙니다.- 항목은 문서 순서대로 놓이고 각자 하나의 포커스 정지입니다. 목적지의 집합이라면 그래야 하고, roving tab index는 바로 그것을 빼앗습니다.
- 현재 목적지는
aria-current="page"를 답니다.aria-pressed는 절대 아닙니다. 그것은 토글로 만들어 버립니다.
- 현재 목적지는 selected로 표시됩니다. Flutter에서
aria-current에 가장 가까운 말이자, 항목을 토글로 알리지 않는 말입니다. - 각 항목은 이름과 탭 동작을 든 버튼 노드이고, 안의 그림은 제외됩니다. 그래서 글리프가 읽을 것 하나를 더 만들지 않습니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
<PlBottomNavigationItem> children | items: List<PlBottomNavigationItem<T>> | 바가 자기 멤버에 대해 판단해야 하는데 Widget은 불투명합니다. PlAccordion과 PlTable이 이미 쓰는 관용구입니다. |
항목의 children | String인 label | 그려지는 이름이자 동시에 안내되는 이름입니다. 위젯은 앞의 하나만 될 수 있고, 문자열만 둘 다 될 수 있습니다. |
value / defaultValue / onValueChange | value / onChanged | Flutter 자체 컨트롤이 controlled이고, 콜백 이름도 그쪽 이름입니다. |
position | — | Flutter 화면에는 빠져나올 페이지 스크롤이 없습니다. 바가 어디 놓일지는 앱의 스캐폴드가 정합니다. |
href | — | 링크 요소도 없고 Flutter 앱을 크롤링하는 것도 없습니다. 라우터를 부르는 자리는 onChanged입니다. |
aria-current="page" | selected 플래그 | Flutter의 시맨틱 트리에는 current가 없습니다. |
className, style | — | 전달할 클래스 목록도 style 속성도 없습니다. |