PlSidebar
페이지 콘텐츠 옆의 열이고, 창이 그것을 담기에 너무 좁아지면 drawer가 됩니다. 하나의 패널을 두 모습으로 보여 주므로, 브레이크포인트에서 컴포넌트를 바꿔 끼울 일이 없습니다.
import { PlPageLayout, PlSidebar } from 'plass-ui';
<PlPageLayout sidebar={<PlSidebar label="Main navigation">{nav}</PlSidebar>}>{page}</PlPageLayout>;import 'package:plass_ui/plass_ui.dart';
PlPageLayout(
sidebar: PlSidebar(semanticLabel: 'Main navigation', child: navigation),
child: page,
);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은 그림자 없음 |
| side | 'start' | 'end' | 'start' | 어느 끝을 차지하는지. 물리적이 아니라 논리적입니다. PlPageLayout 안에서는 어느 슬롯에 넘겼는지가 이미 정하므로 다시 쓸 필요가 없습니다 |
| width | number | string | — | 열의 너비 — 픽셀 수 또는 CSS 길이. 없으면 size가 함의하는 너비입니다 |
| minWidth | number | string | 160 | 얼마나 좁게까지 끌 수 있는지 |
| maxWidth | number | string | 480 | 그리고 얼마나 넓게까지 |
| resizable | boolean | false | 안쪽 가장자리를 끌어 열의 너비를 바꿀 수 있게 합니다 |
| onResize | (width: number) => void | — | 가장자리를 끄는 동안 픽셀 너비와 함께 발생합니다 |
| onResizeEnd | (width: number) => void | — | 놓았을 때 같은 숫자와 함께 한 번 발생합니다 |
| collapseBelow | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none' | — | 열이기를 그만두고 drawer가 되는 창 너비. PlPageLayout의 값이 기본이고, 레이아웃 밖에서는 none입니다 |
| open | boolean | — | drawer가 열려 있는지. 접힌 뒤에만 뜻이 있습니다. PlPageLayout 안에서는 레이아웃이 쥡니다 |
| defaultOpen | boolean | false | 레이아웃 밖의 uncontrolled sidebar가 시작하는 상태 |
| onOpenChange | (open: boolean) => void | — | drawer가 열리거나 닫힐 때 |
| sticky | boolean | true | 페이지가 지나갈 때 열이 자기 자리를 지키는지. 필요 없을 때는 아무 비용도 들지 않습니다 |
| title | ReactNode | — | drawer일 때만 그려지는 제목. 열에는 자기가 무엇인지 말해 줄 페이지가 둘레에 있지만, 페이지를 덮은 패널에는 없습니다 |
| divider | boolean | true | 안쪽 가장자리 — 콘텐츠를 마주하는 쪽 — 에 헤어라인을 그립니다 |
| padded | boolean | true | gutter와 내용 위아래의 공기 |
| label | string | 'Sidebar' | 영역이 불리는 이름. sidebar가 둘인 페이지는 반드시 써야 합니다 |
| closeLabel | string | 'Close sidebar' | 접힌 뒤 drawer의 닫기 버튼이 말하는 내용 |
| resizeLabel | string | 'Resize sidebar' | 드래그 손잡이가 불리는 이름 |
| children | ReactNode | — | 안에 든 전부 — nav, 필터 패널, 목차 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | PlassVariant | PlassVariant.glass | 패널의 재질. 색은 들어가지 않습니다 — 위에 얹힌 것들이 자기 색을 갖고 옵니다 |
| size공통 | PlassSize | PlassSize.md | 패널의 기본 너비와 내용 둘레의 공기 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | int | 0 | 그림자 깊이. 0은 그림자 없음 |
| side | PlassSidebarSide? | PlassSidebarSide.start | 어느 끝을 차지하는지. 물리적이 아니라 논리적입니다. PlPageLayout 안에서는 어느 슬롯에 넘겼는지가 이미 정하므로 다시 쓸 필요가 없습니다 |
| width | double? | — | 열의 너비 — 픽셀 수 또는 CSS 길이. 없으면 size가 함의하는 너비입니다 |
| minWidth | double | 160 | 얼마나 좁게까지 끌 수 있는지 |
| maxWidth | double | 480 | 그리고 얼마나 넓게까지 |
| resizable | bool | false | 안쪽 가장자리를 끌어 열의 너비를 바꿀 수 있게 합니다 |
| onResize | ValueChanged<double>? | — | 가장자리를 끄는 동안 픽셀 너비와 함께 발생합니다 |
| onResizeEnd | ValueChanged<double>? | — | 놓았을 때 같은 숫자와 함께 한 번 발생합니다 |
| collapseBelow | PlassBreakpoint? | — | 열이 drawer가 되는 창 너비. 없으면 위의 PlPageLayout이 정하고, 레이아웃 밖에서는 접히지 않습니다 |
| open | bool? | — | drawer가 열려 있는지. 접힌 뒤에만 뜻이 있습니다. PlPageLayout 안에서는 레이아웃이 쥡니다 |
| onOpenChanged | ValueChanged<bool>? | — | drawer가 열리거나 닫힐 때 |
| title | Widget? | — | drawer일 때만 그려지는 제목. 없으면 semanticLabel이 제목이 됩니다 — 화면을 덮은 패널은 자기가 그리는 것으로 불립니다 |
| divider | bool | true | 안쪽 가장자리 — 콘텐츠를 마주하는 쪽 — 에 헤어라인을 그립니다 |
| padded | bool | true | gutter와 내용 위아래의 공기 |
| semanticLabel | String | 'Sidebar' | 영역이 불리는 이름. sidebar가 둘인 페이지는 반드시 써야 합니다 |
| closeLabel | String | 'Close sidebar' | 접힌 뒤 drawer의 닫기 버튼이 말하는 내용 |
| resizeLabel | String | 'Resize sidebar' | 드래그 손잡이가 불리는 이름 |
| child | Widget? | — | 안에 든 전부 — nav, 필터 패널, 목차 |
네이티브 <aside> 속성은 모두 그대로 전달됩니다. color와 title은 여기서 Plass의 prop이라 제외됩니다.
PlSidebarTrigger
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| side | 'start' | 'end' | 'start' | 레이아웃의 두 sidebar 중 어느 쪽을 여는지 |
| collapseBelow | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none' | — | 버튼이 나타나는 너비. sidebar가 접히는 그 너비이고, PlPageLayout에서 물려받습니다 |
| icon | ReactNode | — | 글리프. 주지 않으면 여기서 그리는 햄버거입니다 |
| label | string | 'Open sidebar' / 'Close sidebar' | 무엇을 하는지, 말로 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| side | PlassSidebarSide | PlassSidebarSide.start | 레이아웃의 두 sidebar 중 어느 쪽을 여는지 |
| icon | Widget? | — | 글리프. 주지 않으면 여기서 그리는 햄버거입니다 |
| label | String? | 'Open sidebar' / 'Close sidebar' | 무엇을 하는지, 말로 |
| variant공통 | PlassVariant | PlassVariant.ghost | 키의 재질. 이미 시트인 바 위에 앉으므로 기본이 ghost입니다 |
| size공통 | PlassSize | PlassSize.md | 키의 크기 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할 |
나머지는 전부 PlIconButton의 것이고 그대로입니다.
공용 축(variant size color density elevation)이 라이브러리 전체에서 무엇을 뜻하는지는 prop 규약에 있습니다.
두 모습, 하나의 패널
collapseBelow 위에서 sidebar는 레이아웃 안의 <aside>이고 콘텐츠가 그 둘레로 배치됩니다. 아래에서는 같은 children이 scrim 위의 PlDrawer가 되고, focus trap과 Esc와 trigger로 돌아가는 길이 함께 옵니다.
하나의 컴포넌트인 이유는 하나의 것이기 때문이고, 그래야 children이 어느 쪽에서도 한 번만 존재하기 때문입니다. 두 번 그려지면 스크린 리더가 두 번 읽습니다.
둘 중 무엇이 보이는지는 media query가 정하고, 첫 페인트는 CSS가 그 뒤로는 JavaScript가 답합니다. 서버가 보내는 마크업은 열이므로, 그냥 두면 좁은 화면이 전체 너비 sidebar를 그렸다가 곧바로 버리게 됩니다. 브레이크포인트 아래에서 그것을 숨기는 클래스가 그 낭비를 막고, 물어볼 창이 생긴 뒤에 drawer가 존재해야 한다고 정하는 것이 matchMedia입니다.
Examples
side
물리적이 아니라 논리적입니다. start는 영어 페이지의 왼쪽이고 아랍어 페이지의 오른쪽입니다. 내비게이션 레일은 어느 쓰기 방향에서든 자기가 속한 글 옆에 있기 때문입니다.
PlPageLayout 안에서는 sidebar를 어느 슬롯에 넘겼는지가 이미 정하므로, 다시 쓰는 것은 레이아웃과 의견을 달리하는 방법일 뿐입니다.
import { PlPageLayout, PlSidebar } from 'plass-ui';
export default function SidebarSides() {
return (
<div className="h-56 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar size="xs" width={140} label="Navigation">
<span className="text-xs">Navigation</span>
</PlSidebar>
}
endSidebar={
<PlSidebar size="xs" width={140} label="On this page">
<span className="text-xs">On this page</span>
</PlSidebar>
}
>
<p className="p-5 text-sm">
Two columns, one on each end. Neither needs a <code>side</code> of its own: the slot it
was handed to is what decides.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarSides extends StatelessWidget {
const SidebarSides({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 520,
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: const PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.xs,
width: 140,
semanticLabel: 'Navigation',
child: Text('Navigation'),
),
endSidebar: PlSidebar(
size: PlassSize.xs,
width: 140,
semanticLabel: 'On this page',
child: Text('On this page'),
),
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Two columns, one on each end. Neither needs a side of its own: the slot it was '
'handed to is what decides.',
),
),
),
),
);
}
}collapseBelow
열이 drawer가 되는 창 너비입니다. 기본값은 레이아웃 자신의 collapseBelow이고, 레이아웃 밖에서는 none입니다. 되돌릴 방법이 페이지에 없는 채로 접히는 sidebar는 독자가 잃어버린 sidebar이기 때문입니다.
되돌리는 것이 PlSidebarTrigger입니다. PlHeader의 brand 슬롯, 로고 앞에 두세요. 30년의 햄버거가 독자에게 거기를 보라고 가르쳐 온 자리입니다. 상태가 아니라 같은 media query로 숨겨지므로, 페이지가 도착하고 잠시 뒤에 튀어나오는 대신 서버가 보내는 마크업에 들어 있습니다.
title은 sidebar가 drawer일 때만 그려집니다. 열에는 자기가 무엇인지 말해 줄 페이지가 둘레에 있지만, 페이지를 덮은 패널에는 없습니다.
import { PlHeader, PlPageLayout, PlSidebar, PlSidebarTrigger } from 'plass-ui';
export default function SidebarCollapse() {
return (
<div className="h-64 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="lg"
header={
<PlHeader
size="sm"
brand={
<>
<PlSidebarTrigger size="sm" />
<span className="font-semibold">Acme</span>
</>
}
/>
}
sidebar={
<PlSidebar size="sm" label="Main navigation" title="Navigation">
<nav className="flex flex-col gap-2 text-sm">
{['Overview', 'Reports', 'Settings'].map((item) => (
<a key={item} href="#" className="no-underline">
{item}
</a>
))}
</nav>
</PlSidebar>
}
>
<p className="p-5 text-sm">
Below <code>lg</code> the column is a drawer and the hamburger is what brings it back.
Widen the window past 64rem and the button goes away with it.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarCollapse extends StatelessWidget {
const SidebarCollapse({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 360,
height: 280,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: const PlPageLayout(
collapseBelow: PlassBreakpoint.md,
header: PlHeader(
size: PlassSize.sm,
brand: <Widget>[
PlSidebarTrigger(size: PlassSize.sm),
Text('Acme', style: TextStyle(fontWeight: FontWeight.w600)),
],
),
sidebar: PlSidebar(
size: PlassSize.sm,
semanticLabel: 'Main navigation',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: <Widget>[Text('Overview'), Text('Reports'), Text('Settings')],
),
),
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'This frame is narrower than md, so the column is a drawer and the hamburger is '
'what brings it back.',
),
),
),
),
);
}
}resizable
기본은 꺼져 있습니다. 크기를 바꿀 수 있는 sidebar는 그 너비가 독자의 것이 된 sidebar라서, 이걸 켜는 쪽은 보통 onResizeEnd가 알려 주는 값을 저장하기도 합니다.
끌어서 정해진 너비는 state가 아니라 요소에 곧바로 씁니다. 그 숫자에 의존하는 것은 CSS 선언 하나뿐이고, 포인터가 움직일 때마다 setState를 하면 패널의 모든 행이 다시 그려집니다. 호출하는 쪽은 onResize로 매 단계를 그대로 듣습니다.
손잡이는 가장자리 안이 아니라 가장자리를 걸치고 있습니다. 1px 헤어라인은 1px짜리 표적이기 때문입니다. 스크롤바가 하는, 그려지는 것과 잡을 수 있는 것 사이의 같은 분리입니다.
import { useState } from 'react';
import { PlPageLayout, PlSidebar } from 'plass-ui';
export default function SidebarResizable() {
const [width, setWidth] = useState(220);
return (
<div className="h-56 w-full overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar
size="sm"
label="Files"
resizable
width={220}
minWidth={140}
maxWidth={320}
onResize={setWidth}
>
<span className="text-sm">Drag the inner edge.</span>
</PlSidebar>
}
>
<p className="p-5 text-sm">
The column is <strong>{Math.round(width)}px</strong> wide. The handle straddles the edge
rather than sitting inside it, and the arrow keys move it too.
</p>
</PlPageLayout>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarResizable extends StatefulWidget {
const SidebarResizable({super.key});
@override
State<SidebarResizable> createState() => _SidebarResizableState();
}
class _SidebarResizableState extends State<SidebarResizable> {
double _width = 220;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 520,
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.sm,
semanticLabel: 'Files',
resizable: true,
width: 220,
minWidth: 140,
maxWidth: 320,
onResize: (double width) => setState(() => _width = width),
child: const Text('Drag the inner edge.'),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(
'The column is ${_width.round()} wide. The handle straddles the edge rather than '
'sitting inside it, and the arrow keys move it too.',
),
),
),
),
);
}
}variant
세 재질을 컨테이너로 읽은 것입니다. 패널에는 색이 들어가지 않습니다. sidebar에 얹히는 것은 누군가의 내비게이션이고, 그것이 자기 색을 갖고 옵니다.
divider는 안쪽 가장자리(콘텐츠를 마주하는 쪽)를 긋습니다. 바깥쪽 가장자리는 창을 향하고 있고, 그 너머에는 구분할 것이 없습니다.
import { PlPageLayout, PlSidebar, type PlassVariant } from 'plass-ui';
export default function SidebarVariants() {
return (
<div className="grid w-full gap-4 sm:grid-cols-3">
{(['solid', 'glass', 'ghost'] as PlassVariant[]).map((variant) => (
<div key={variant} className="h-40 overflow-hidden rounded-(--plass-radius-md)">
<PlPageLayout
height="auto"
scroll="content"
collapseBelow="none"
sidebar={
<PlSidebar size="xs" width={90} variant={variant} label={variant}>
<span className="text-xs">{variant}</span>
</PlSidebar>
}
>
<p className="p-3 text-xs">The panel is never dyed.</p>
</PlPageLayout>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SidebarVariants extends StatelessWidget {
const SidebarVariants({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 16,
children: <Widget>[
for (final PlassVariant variant in PlassVariant.values)
SizedBox(
width: 220,
height: 150,
child: ClipRRect(
borderRadius: BorderRadius.circular(PlassTokens.radius[PlassSize.md]!),
child: PlPageLayout(
collapseBelow: null,
sidebar: PlSidebar(
size: PlassSize.xs,
width: 90,
variant: variant,
semanticLabel: variant.name,
child: Text(variant.name),
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('The panel is never dyed.'),
),
),
),
),
],
);
}
}sticky
기본으로 켜져 있고, 필요 없을 때는 아무 비용도 들지 않습니다. 페이지가 스크롤될 때 열은 sticky가 되고 header 아래로 창에 남은 만큼 높아집니다. --p-layout-header와 --p-layout-footer를 재는 이유가 그것입니다. 콘텐츠만 스크롤될 때는 열이 이미 레이아웃만큼 높으므로 아무것도 달라지지 않습니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
창 너비 기준의 collapseBelow, 기본값은 레이아웃의 것 | 같지만, 레이아웃의 답은 자기 너비 기준 | LayoutBuilder는 레이아웃이 받은 constraints를 보고, media query는 창만 봅니다. 여기에 값을 주면 창을 재게 되며, 그것이 곧 재정의입니다. |
'none' | null | "정해 둔 하한이 없다"를 Dart가 나타내는 방식입니다. |
| media query로 숨기는 trigger | 아예 만들지 않는 trigger | 웹에서 그 클래스는 서버가 보내는 마크업에 버튼을 남겨 두기 위한 것입니다. 여기에는 붙들 첫 페인트가 없습니다. |
sticky | — | 열은 레이아웃이 준 band만큼 높습니다. 자리를 지킬 문서 스크롤이라는 것이 없습니다. |
aria-label로 물러나는 title | semanticLabel로 물러나고 그려지는 title | PlDrawer는 자기가 그리는 것으로 불리므로, 영역의 이름이 보이지 않는 라벨이 아니라 제목이 됩니다. |
aria-valuenow가 붙은 role="separator" 손잡이 | 논리 픽셀 값이 붙은 Semantics(slider: true) 손잡이 | Flutter semantics에는 separator role도 valuenow도 없습니다. 손잡이는 실제로 그것인 것(값을 올리고 내릴 수 있는 컨트롤)이 됩니다. |
| 요소에 직접 쓰는 너비 | ValueNotifier에 담은 너비 | 같은 결정의 다른 철자입니다. 그 숫자에 의존하는 것은 상자 하나뿐이고, 포인터가 움직일 때마다 패널을 다시 지으면 그 안의 모든 행이 다시 지어집니다. |
label | semanticLabel | Flutter의 이름입니다. |
className, style, 네이티브 속성 | — | 전달할 class 목록도 style 속성도 없습니다. |
Accessibility
- 열은 진짜
<aside>이고, 그것이complementarylandmark입니다. label은 사실상 필수이고 기본값은Sidebar입니다. sidebar가 둘인 페이지는 각각에 이름을 반드시 줘야 합니다. 그러지 않으면 스크린 리더가 "complementary"라는 영역을 둘 내놓습니다.- 접힌 상태에서는 dialog입니다. focus가 갇히고, Esc가 닫고, 뒤의 페이지는 inert가 되고, focus는 열었던 것으로 돌아갑니다. 전부
PlDrawer의 것이고, 그건 Base UI의 것입니다. - trigger는
aria-expanded를 지니므로, 누르기 전에 패널이 열려 있는지 스크린 리더가 먼저 알려 줍니다. - 크기 조절 손잡이는
aria-orientation="vertical"인role="separator"이고,resizable인 동안 tab stop이며 ← →로 움직입니다. 키 누름은 그 자체로 하나의 완결된 제스처이므로onResize와 함께onResizeEnd도 발생시킵니다. - 드래그는
preventDefault대신-webkit-user-select로 페이지의 텍스트 선택을 거둡니다. WebKit이 구현한 유일한 이름이고,preventDefault는 브라우저가 손잡이에 focus를 주는 것까지 막습니다.
- 열은
SemanticsRole.complementary를 선언합니다. 반대쪽의<aside>태그가 지니는 것과 같은 landmark입니다. semanticLabel이 이름이고 기본값은Sidebar입니다. sidebar가 둘인 화면은 각각에 이름을 반드시 줘야 합니다. Flutter는 라벨 없이 중복된 landmark를 대놓고 거부합니다.- 접힌 상태에서는
PlDrawer입니다. focus가 갇히고, barrier가 닫고, focus는 열었던 것으로 돌아갑니다. - 크기 조절 손잡이는 너비를 값으로 갖는
Semantics(slider: true)이고,onIncrease/onDecrease가 화살표 키와 같은 단계에 연결되어 있습니다. 포인터 없이도 스크린 리더가 가장자리를 옮길 수 있습니다. - trigger는 누르면 무엇이 일어나는지 말해 주는 이름이 붙은 진짜
PlIconButton입니다.