PlPageLayout
페이지를 걸어 두는 뼈대입니다. header와 footer, sidebar 하나 또는 둘, 그리고 그 사이의 콘텐츠를 배치합니다. 자기 표면은 그리지 않고 배치와 landmark만 보탭니다.
import { PlPageLayout } from 'plass-ui';
<PlPageLayout header={<header>…</header>} sidebar={<nav>…</nav>} footer={<footer>…</footer>}>
{page}
</PlPageLayout>;import 'package:plass_ui/plass_ui.dart';
PlPageLayout(
header: const PlToolbar(child: Text('Acme')),
sidebar: const SizedBox(width: 200, child: Text('Navigation')),
footer: const PlToolbar(side: PlassSide.bottom, child: Text('© 2026 Acme')),
child: page,
);Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| header | ReactNode | — | 위쪽 바. 보통 PlHeader가 들어갑니다 |
| footer | ReactNode | — | 맨 아래 시트. 보통 PlFooter가 들어갑니다 |
| sidebar | ReactNode | — | 앞쪽 열 — 영어 페이지의 왼쪽, 아랍어 페이지의 오른쪽. 안에 든 PlSidebar는 자기가 어느 끝인지 전달받으므로 side를 쓸 필요가 없습니다 |
| endSidebar | ReactNode | — | 뒤쪽 열. 한쪽은 내비게이션, 다른 쪽은 목차나 인스펙터나 필터 패널인 레이아웃을 위한 것입니다 |
| headerSpan | 'full' | 'content' | 'full' | 헤더와 사이드바 중 어느 쪽이 위 모서리를 차지하는지. full은 웹사이트의 배치, content는 애플리케이션의 배치입니다 |
| footerSpan | 'full' | 'content' | 'full' | 푸터에 대한 같은 질문. 따로 답할 값어치가 있습니다 — 전체 높이 내비게이션 레일이 있는 대시보드도 저작권 줄은 보통 콘텐츠 아래에 둡니다 |
| scroll | 'page' | 'content' | 'page' | 무엇이 스크롤되는지 — 문서 전체인지, 헤더와 푸터 사이만인지. page가 거의 모든 페이지가 원하는 값입니다 |
| height | 'viewport' | 'auto' | number | string | 'viewport' | 레이아웃의 높이. 페이지가 스크롤될 때는 최소 높이, 콘텐츠만 스크롤될 때는 정확한 높이가 됩니다 |
| collapseBelow | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none' | 'md' | 사이드바가 열이기를 그만두고 드로어가 되는 창 너비. none이면 어떤 너비에서도 열로 남습니다 |
| sidebarOpen | boolean | — | 앞쪽 사이드바의 드로어가 열려 있는지. onSidebarOpenChange와 함께 controlled로 씁니다 |
| defaultSidebarOpen | boolean | false | 어느 상태로 시작할지 |
| onSidebarOpenChange | (open: boolean) => void | — | 앞쪽 드로어가 열리거나 닫힐 때 |
| endSidebarOpen | boolean | — | 뒤쪽 사이드바에 대한 같은 세 가지 |
| defaultEndSidebarOpen | boolean | false | 뒤쪽 드로어가 시작하는 상태 |
| onEndSidebarOpenChange | (open: boolean) => void | — | 뒤쪽 드로어가 열리거나 닫힐 때 |
| skipLink | boolean | true | 문서 맨 앞에 본문으로 건너뛰는 링크를 둡니다. focus를 받을 때만 그려집니다 |
| skipLabel | ReactNode | 'Skip to content' | 그 링크가 말하는 내용 |
| mainId | string | 'main' | 건너뛰기 링크가 향하는 id. <main>에 붙습니다 |
| mainProps | Omit<ComponentPropsWithoutRef<'main'>, 'id' | 'children'> | — | <main>에 필요한 나머지 — className, aria-label |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 건너뛰기 링크가 켜지는 색 계열. 레이아웃 자체는 아무 표면도 그리지 않습니다 |
| children | ReactNode | — | 페이지. <main> 안에 그려집니다 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| header | Widget? | — | 위쪽 바. 보통 PlHeader가 들어갑니다 |
| footer | Widget? | — | 맨 아래 시트. 보통 PlFooter가 들어갑니다 |
| sidebar | Widget? | — | 앞쪽 열 — 영어 페이지의 왼쪽, 아랍어 페이지의 오른쪽. 안에 든 PlSidebar는 자기가 어느 끝인지 전달받으므로 side를 쓸 필요가 없습니다 |
| endSidebar | Widget? | — | 뒤쪽 열. 한쪽은 내비게이션, 다른 쪽은 목차나 인스펙터나 필터 패널인 레이아웃을 위한 것입니다 |
| headerSpan | PlPageLayoutSpan | PlPageLayoutSpan.full | 헤더와 사이드바 중 어느 쪽이 위 모서리를 차지하는지. full은 웹사이트의 배치, content는 애플리케이션의 배치입니다 |
| footerSpan | PlPageLayoutSpan | PlPageLayoutSpan.full | 푸터에 대한 같은 질문. 따로 답할 값어치가 있습니다 — 전체 높이 내비게이션 레일이 있는 대시보드도 저작권 줄은 보통 콘텐츠 아래에 둡니다 |
| collapseBelow | PlassBreakpoint? | PlassBreakpoint.md | sidebar가 열이기를 그만두고 drawer가 되는 너비. null이면 어떤 너비에서도 열로 남습니다. 창이 아니라 이 레이아웃이 받은 공간과 비교합니다 |
| sidebarOpen | bool? | — | 앞쪽 sidebar의 drawer가 열려 있는지. 넘기면 controlled가 되어 레이아웃은 상태를 쥐지 않습니다 |
| onSidebarOpenChanged | ValueChanged<bool>? | — | 앞쪽 드로어가 열리거나 닫힐 때 |
| endSidebarOpen | bool? | — | 뒤쪽 sidebar에 대한 같은 둘 |
| onEndSidebarOpenChanged | ValueChanged<bool>? | — | 뒤쪽 드로어가 열리거나 닫힐 때 |
| mainSemanticLabel | String? | — | 스크린 리더가 main 영역을 부르는 이름. 없으면 안에 든 것으로 불립니다 |
| child | Widget? | — | 페이지. <main> 안에 그려집니다 |
네이티브 <div> 속성은 모두 root로 그대로 전달됩니다. color는 여기서 Plass의 prop이라 제외됩니다.
레이아웃은 받은 공간을 채웁니다. header, Expanded band, footer의 구성이라 높이가 정해진 것 아래에 두세요. 콘텐츠는 scroll view로 감싸지 않습니다. 무엇이 어느 방향으로 스크롤되는지는 child에 넣은 것의 몫입니다.
공용 축이 라이브러리 전체에서 무엇을 뜻하는지는 prop 규약에 있습니다.
범위
landmark입니다. <div>만으로 조립한 페이지는 스크린 리더가 구분 없는 영역 하나로 내놓고 검색 엔진도 구분 없는 덩어리 하나로 읽는 페이지입니다. 같은 페이지를 <header>, <nav>, <aside>, <main>, <footer>로 지으면 목차가 있는 페이지가 됩니다.
그 태그들은 이 컴포넌트가 배치하는 컴포넌트들이 냅니다. 레이아웃 자신이 문서에 보태는 것은 요소 하나, 그리고 <main>과 거기로 건너뛰는 링크뿐입니다.
gutter도 measure도 그리지 않습니다. 그건 PlContainer의 일이고, 안에 하나 넣으면 됩니다. 그래야 한 route에서는 넓은 대시보드, 다음 route에서는 좁은 글이 될 수 있습니다.
Examples
headerSpan · footerSpan
header와 sidebar 중 어느 쪽이 위 모서리를 차지하는지입니다.
full은 웹사이트의 배치입니다. 바 하나가 전체 너비를 가로지르고 열은 그 아래에서 시작합니다. content는 애플리케이션의 배치입니다. sidebar가 창의 전체 높이를 차지하고 바는 그 사이에 앉아, 사이트가 아니라 화면에 속합니다.
footer는 같은 질문에 따로 답합니다. 전체 높이 내비게이션 레일이 있는 대시보드도 저작권 줄은 보통 레일 아래가 아니라 콘텐츠 아래에 두기 때문입니다.
scroll · height
scroll="page"가 기본값이고, 거의 모든 페이지가 원하는 값입니다. 문서가 스크롤되고, 휴대폰에서 브라우저 주소 표시줄이 숨고, 뒤로 가기에서 스크롤 위치가 복원되고, sticky header는 아무것도 밀어내지 않고 자기 자리를 지킵니다.
scroll="content"는 레이아웃을 정확히 창 높이로 만들고 바 사이 영역만 스크롤합니다. 페이지가 문서가 아니라 작업 공간일 때 쓰세요.
height는 기본이 viewport, 페이지가 아닌 레이아웃(미리 보기, 더 큰 도구의 한 pane)에는 auto, 그 외에는 아무 CSS 길이나 됩니다. 페이지가 스크롤될 때는 최소 높이, 콘텐츠만 스크롤될 때는 정확한 높이입니다.
skipLink
기본으로 켜져 있고, 여기서 유일하게 스타일 결정이 아닌 항목입니다. 내비게이션에 링크가 마흔 개 있는 페이지에 도착한 키보드 사용자는 글에 닿기까지 매 페이지마다 마흔 개를 지나야 합니다. 이 링크 하나가 그걸 면하게 해 주고, focus를 받기 전까지는 보이지 않으므로 눈으로 읽는 사람에게는 아무 비용도 들지 않습니다.
mainId는 쌍의 양쪽을 함께 바꿉니다: <main>의 id와 링크가 가리키는 href.
collapseBelow
sidebar가 열이기를 그만두고 drawer가 되는 창 너비입니다. none이면 어떤 너비에서도 열로 남고, sidebar가 없는 레이아웃과 이 페이지의 미리 보기들이 그 값을 씁니다.
sidebar가 열이기를 그만두고 drawer가 되는 너비입니다. null이면 어떤 너비에서도 열로 남고, sidebar가 없는 레이아웃과 이 페이지의 미리 보기들이 그 값을 씁니다.
비교 대상은 창이 아니라 이 레이아웃이 받은 공간입니다. media query가 못 하는 것이 이것입니다. 어떤 pane 안에 든 앱 셸은 창이 아니라 그 pane이 좁을 때 접힙니다.
각 drawer가 열려 있는지도 레이아웃이 쥡니다. route가 바뀔 때 닫을 수 있도록, 앞쪽 열은 sidebarOpen / onSidebarOpenChange, 뒤쪽 열은 endSidebarOpen / onEndSidebarOpenChange입니다.
바를 재는 방법
자기 자리를 지키는 sidebar는 header 아래에서 시작해야 하는데, 그 높이는 header 말고 아무도 모릅니다. 그래서 레이아웃은 두 바를 재서 창에서 얼마를 가져가는지 자기 root에 씁니다: --p-layout-header와 --p-layout-footer, 그리고 각각의 -inset.
하나가 아니라 둘인 이유는, 바가 어떻게 배치되었는지에 따라 가져가는 것이 다르기 때문입니다. sticky 바는 여전히 흐름 안에 있으므로 자리를 따로 비워 둘 필요가 없지만, 창 위쪽을 늘 가로막고 있으므로 열은 그 아래에서 시작해야 합니다. fixed 바는 흐름 밖이므로 페이지가 그 높이를 비워 둬야 합니다. 둘 중 어느 쪽인지는 prop으로 전달받는 대신 요소에서 읽습니다.
등록하지 않은 바는 0으로 남습니다. 측정은 querySelector가 아니라 슬롯이 스스로 참여하는 계약이라서, render={<MyBar />}로 그려진 바도 그렇지 않은 바만큼 확실하게 찾힙니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
scroll, height | — | 레이아웃은 받은 공간을 채우고, 스크롤되는 것은 child에 넣은 것입니다. 영역 대신 스크롤할 문서라는 것이 없습니다. |
측정된 --p-layout-* 속성 | — | Column이 이미 그 산수를 끝냈습니다. header 아래의 band는 정확히 header가 남긴 만큼이라 잴 것이 없습니다. |
창 너비 기준의 collapseBelow | 이 레이아웃 자신의 너비 기준 | LayoutBuilder는 레이아웃이 받은 constraints를 봅니다. media query는 창만 봅니다. |
'none' | null | "정해 둔 하한이 없다"를 Dart가 나타내는 방식입니다. |
skipLink, skipLabel, mainId | — | 건너뛰기 링크는 fragment로 가는 링크입니다. fragment가 없고, 순회 순서도 문서가 아니라 semantics 트리의 것입니다. |
mainProps, color | mainSemanticLabel | React 빌드가 <main>에 얹던 것 중 여기에 대응하는 것은 이름뿐입니다. 레이아웃은 아무것도 칠하지 않으므로 나를 색도 없습니다. |
defaultSidebarOpen | — | uncontrolled가 기본입니다. sidebarOpen을 빼면 레이아웃이 닫힌 상태로 시작해 상태를 쥡니다. |
children | child | Flutter의 이름입니다. |
Accessibility
- children은 진짜
<main>안에 들어가고, 그것이mainlandmark입니다. 페이지당 정확히 하나여야 하며, 그걸 보장하는 것이 레이아웃입니다. - 건너뛰기 링크는 문서의 맨 앞이고, 숨겨지는 대신 1px로 잘려 있어서 Tab 키가 찾을 수 있습니다.
hidden이면 화면과 함께 접근성 트리에서도 사라져 tab할 것이 남지 않습니다. <main>에는tabindex를 주지 않습니다. 거기로 건너뛰는 것은 읽는 위치를 옮기는 일이고 그것이 핵심입니다. focus 가능하게 만들면 모든 페이지에 tab stop이 하나 늘어납니다.- 이름 붙일 영역이 둘 이상인 페이지라면
aria-label은mainProps에 넣습니다. - 레이아웃 자체는 어떤 role도 선언하지 않습니다.
<div>하나를 보탤 뿐이고, landmark는 안에 든 컴포넌트들이 그리는 태그에서 나옵니다.
- 콘텐츠는
SemanticsRole.main으로 감싸집니다. 반대쪽의<main>요소가 하는 것과 같은 주장입니다: 여기가 chrome이 아닌 부분이라는 것. - 이름 붙일 영역이 둘 이상인 화면이라면
mainSemanticLabel이 그 영역의 이름입니다. 없으면 안에 든 것으로 불립니다. - 레이아웃은 그 밖에 아무 role도 선언하지 않습니다. 나머지 이름은 안에 든 컴포넌트들에서 나옵니다.