본문으로 건너뛰기

PlTable

유리 시트 위에 놓인 데이터 격자입니다. 마크업이 아니라 column과 row를 받기 때문에, 제목 줄과 그 아래 셀이 서로 어긋날 수 없습니다.

React
tsx
import { PlTable, type PlTableColumn } from 'plass-ui';

const columns: PlTableColumn<Invoice>[] = [
  { key: 'id', header: 'Invoice' },
  { key: 'customer', header: 'Customer' },
  { key: 'total', header: 'Total', align: 'end', render: (row) => `$${row.total}` }
];

<PlTable columns={columns} rows={invoices} caption="Recent invoices" hoverable />;
dart
import 'package:plass_ui/plass_ui.dart';

PlTable<Invoice>(
  caption: const Text('Recent invoices'),
  hoverable: true,
  rows: invoices,
  columns: <PlTableColumn<Invoice>>[
    PlTableColumn<Invoice>(
      header: const Text('Invoice'),
      cell: (Invoice row, int index) => Text(row.id),
    ),
    PlTableColumn<Invoice>(
      header: const Text('Customer'),
      cell: (Invoice row, int index) => Text(row.customer),
    ),
    PlTableColumn<Invoice>(
      header: const Text('Total'),
      align: PlassAlign.end,
      cell: (Invoice row, int index) => Text(row.total),
    ),
  ],
);

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 | 30그림자 깊이. 0은 그림자 없음
columns * readonly PlTableColumn<Row>[]열 정의. 나타나는 순서대로
rows * readonly Row[]행 데이터
getRowKey(row: Row, index: number) => Key행마다의 안정적인 key. 기본값은 index라서 정렬이나 필터가 있는 표에는 맞지 않습니다
captionReactNode표 위에 놓이며, 표의 접근 가능한 이름으로 읽힙니다
emptyReactNode'No data'rows가 비었을 때 행 대신 보여 줄 내용
stripedbooleanfalse한 행 걸러 하나씩 옅게 칠합니다. 눈이 가로로 따라가야 하는 넓은 표에서 유용합니다
hoverablebooleanfalse포인터 아래 행에 불을 켭니다
stickyHeaderbooleanfalse행이 밑으로 지나가는 동안 열 이름을 고정합니다. 스크롤될 상자가 있어야 의미가 있습니다 — 보통은 maxHeight
maxHeightnumber | string격자 높이의 상한. 넘으면 페이지가 늘어나는 대신 시트 안에서 행이 스크롤됩니다. caption은 그 위에 남습니다
onRowClick(row: Row, index: number) => void행을 활성화할 수 있게 만듭니다. hover 처리도 함께 켜집니다
Prop타입기본값설명
columns * List<PlTableColumn<T>>열 정의. 나타나는 순서대로
rows * List<T>행 데이터
rowKeyLocalKey Function(T row, int index)?행마다의 안정적인 key. 없으면 위치로 식별되고, 정렬이나 필터가 있는 표에는 맞지 않습니다
captionWidget?격자 위, 시트 안에 그려집니다
emptyWidget?Text('No data')rows가 비었을 때 행 대신 보여 줄 내용
stripedboolfalse한 행 걸러 하나씩 옅게 칠합니다. 눈이 가로로 따라가야 하는 넓은 표에서 유용합니다
hoverableboolfalse포인터 아래 행에 불을 켭니다
stickyHeaderboolfalse행이 밑으로 지나가는 동안 열 이름을 고정합니다. 스크롤될 상자가 있어야 의미가 있습니다 — 보통은 maxHeight
maxHeightdouble?격자 높이의 상한, 논리 픽셀. 넘으면 시트 안에서 행이 스크롤됩니다. caption은 그 위에 남습니다
onRowPressedvoid Function(T row, int index)?행을 활성화할 수 있게 만듭니다. hover 처리도 함께 켜집니다
variant공통PlassVariantPlassVariant.glass표면의 재질. 색이 들어간 유리 / 맑은 유리 시트 / 없음
size공통PlassSizePlassSize.md셀의 타입 스케일과 행 높이
color공통PlassColorPlassColor.primary의미론적 색 역할. hover 틴트와 focus ring까지만 닿습니다 — 데이터는 자기 색을 가지고 옵니다
density공통PlassDensityPlassDensity.standard셀 여백만 바꿉니다. 타입 스케일은 그대로
elevation공통int0그림자 깊이. 0은 그림자 없음
semanticLabelString?표를 스크린 리더가 부를 이름. caption은 그려지면서 읽히므로, 둘이 달라야 할 때만 씁니다

네이티브 <div> 속성은 격자가 놓인 시트로 그대로 전달됩니다. color는 위 표의 color와 충돌해서 제외됩니다.

PlTableRow에 대해 generic이라 forwardRef가 아닙니다. React.forwardRef로 감싼 컴포넌트는 타입 파라미터를 잃는데, 이 API의 핵심이 바로 행의 타입입니다. Rowany로 넓혀 가며 ref를 제공하느니 제공하지 않습니다.

표는 행 타입에 대해 제네릭입니다(PlTable<Invoice>). 그리고 그것이 이 API의 핵심입니다. column은 타입이 있는 행을 받아 위젯을 돌려줍니다.

격자는 Flutter 자신의 Table이 배치하고, table·row·cell·column header semantics도 거기서 나옵니다. column의 너비는 그 안의 내용에서 측정됩니다. 브라우저의 자동 표 레이아웃이 재는 방식 그대로라, 같은 데이터가 두 패키지에서 같은 모양으로 나옵니다.

PlTableColumn

Prop타입기본값설명
key * string열을 식별하고, render가 없으면 각 행에서 읽을 속성 이름이 됩니다
headerReactNode열 제목. 생략하면 key가 그대로 쓰입니다
widthnumber | string기본 너비. 숫자는 px, 문자열은 CSS 길이. 표는 여전히 폭을 채우도록 열을 조정하므로 보장이 아니라 출발 비율입니다
align공통'start' | 'center' | 'end''start'텍스트 정렬. 숫자는 자릿수가 맞도록 보통 end
render(row: Row, index: number) => ReactNode셀을 직접 그립니다. 없으면 row[key]를 그대로 렌더링합니다
Prop타입기본값설명
cell * Widget Function(T row, int index)행에서 셀을 만듭니다. Dart에는 임의의 타입에 대한 row[key]가 없으니 필수입니다
headerWidget?열 제목. 생략하면 제목 없는 열이 됩니다 — 액션 열이 원하는 것이고, 나머지 열은 원하지 않는 것입니다
widthdouble?고정 너비, 논리 픽셀. 없으면 내용만큼 넓어진 뒤 남은 폭을 flex만큼 나눠 갖습니다
flexdouble1모든 열이 내용만큼 자리를 잡은 뒤 남은 폭에서 이 열이 가져가는 몫. React의 width: 30%에 해당합니다
align공통PlassAlignPlassAlign.start텍스트 정렬. 숫자는 자릿수가 맞도록 보통 end

cell이 필수라는 점이 두 빌드 사이의 유일한 실제 차이입니다. React에서는 column이 key로 속성 이름을 가리키고 render가 없으면 셀이 row[key]가 되는데, Dart에는 임의의 타입에 대한 그런 조회가 없습니다. 행의 타입을 dynamic으로 넓혀 가며 얻는 것보다, 접근자를 한 줄 쓰는 편이 싼 거래입니다.

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

Examples

variant

격자 아래의 시트이고, 다른 모든 컨테이너와 같은 세 가지 재질을 쓰며 색이 들어가지 않습니다.

셋 중 어느 것에도 열 이름 뒤에 띠가 없습니다. 헤더는 조금 더 진한 선 위에 놓인 muted semibold 텍스트이고, 그 아래 행들은 중립 divider 잉크로 나뉩니다. PlCardPlList에 금을 긋는 것과 같은 헤어라인입니다. 격자 맨 위를 칠한 띠는 데이터를 chrome처럼 보이게 만드는 가장 빠른 방법이고, 표의 무게를 하나도 필요 없는 자리에 몰아 줍니다.

예외는 stickyHeader인데, 거기서의 칠은 장식이 아닙니다. 고정된 헤더 밑으로 행이 그대로 지나가므로 빛을 막을 것이 필요합니다.

React

columns

column은 key로 읽어 올 속성을 지정하고, 셀이 문자열이나 숫자가 아니면 render가 대신합니다. width는 첫 행의 셀이 아니라 <col>에 붙습니다. <th>에 준 너비는 브라우저가 나머지 모든 행과 다시 협상하는 너비입니다.

column이 가리키는 것은 행에서 셀을 어떻게 꺼내는지, 그것뿐입니다. cell은 행과 그 위치를 받아 위젯을 돌려줍니다.

너비는 두 가지 형태로 오고, 둘은 서로 다른 질문입니다. width는 논리 픽셀 단위의 길이로, 정확히 그만큼이어야 하는 column에 씁니다. 고정 폭 액션 열, 상태 pill 같은 것. flex는 모든 column이 자기 내용만큼 자리를 잡은 뒤 남은 폭을 나눠 갖는 몫이고, React 빌드의 width: '30%'가 실제로 뜻하던 것이 바로 이것입니다.

align의 기본값은 start입니다. 숫자는 자릿수가 세로로 맞도록 보통 end가 맞습니다.

React

striped와 hoverable

striped는 한 행 걸러 하나씩 --plass-stripe로 칠합니다. 유리를 한 겹 더 얹는 대신 중립 잉크를 쓰는 것이고, 눈이 가로로 길게 따라가야 하는 넓은 표에서는 도움이 되지만 좁은 표에서는 소음입니다. hoverable은 포인터 아래의 행을 색 계열의 soft 틴트로 밝힙니다.

React

stickyHeader와 maxHeight

한 아이디어의 두 절반이고, 한쪽만으로는 거의 쓸모가 없습니다.

maxHeight격자의 높이를 제한하고, 그 높이를 넘으면 시트가 늘어나는 대신 시트 안에서 행이 스크롤됩니다. stickyHeader는 그 스크롤되는 상자의 맨 위에 열 이름을 고정합니다. 고정 없이 높이만 제한하면 이름이 스크롤되어 사라지고 남는 것은 이름표 없는 숫자 격자이며, 높이 제한 없이 고정만 하면 이름이 붙어 있을 상자가 없어서 아무 일도 일어나지 않습니다.

caption은 스크롤되는 것 에 놓입니다. 미끄러져 사라지는 제목은 자기가 이름 붙인 표에서 떨어져 나가기 때문입니다.

고정된 헤더는 격자가 칠을 그리는 유일한 자리입니다. 행이 그 바로 밑을 지나가므로 반투명한 헤더는 행을 그대로 통과시킵니다. 그래서 시트의 가장 짙은 유리를 페이지 자신의 surface 색 위에 얹은 것. 빛을 막기 위해 쌓아 올린 불투명한 두 겹입니다.

maxHeight는 픽셀 숫자이거나 아무 CSS 길이입니다. caption은 스크롤 상자 바깥에 제목으로 그려지고 aria-hidden이 붙습니다. 같은 말을 담은 진짜 <caption><table> 안에 따로 들어가 스크린리더에게 읽힙니다. 한 문장이 두 벌인 이유는 바로 다음 절에 있습니다. id로 표에 이름을 붙이려면 id를 만들어야 하고, id를 만드는 컴포넌트는 client component입니다.

고정된 헤더 아래의 선은 border가 아니라 inset 그림자인데, 이것은 취향이 아닙니다. border-collapse: collapse는 셀의 border를 의 border 격자에 넘기고, 그 격자는 position: sticky인 셀을 따라가지 않습니다. border로 그린 고정 헤더는 자기 밑줄을 스크롤 맨 위에 두고 갑니다.

maxHeight는 논리 픽셀 단위의 double입니다. 스크롤 뷰는 언제나 거기 있으므로, 높이 제한이 있든 없든 표는 자기보다 작은 상자 안에서 넘치는 대신 스크롤됩니다.

고정된 띠는 두 번째 격자가 아닙니다. 각자 자기 내용에서 잰 두 격자는 열 너비에 합의할 수 없으므로, Table은 여전히 하나이고 헤더 행도 그 안에 있습니다. 스크롤 위에 얹힌 띠는 그 행의 복사본이고, 각 셀은 진짜 헤더 셀이 배치된 너비의 상자에 담깁니다. 복사본은 아무 이름도 알리지 않습니다. 격자가 이미 열 제목으로 안내하기 때문입니다.

React

onRowClickonRowPressed

행을 활성화할 수 있게 만들고, hover 처리도 함께 켭니다. 각 행이 focus stop을 갖고 EnterSpace에 반응하므로, 포인터 없이도 행에 닿을 수 있습니다.

안에서 눌린 키는 건드리지 않습니다. 셀은 자기 Enter가 붙은 링크나 버튼을 담을 수 있고, 둘 다 실행하면 행을 열면서 동시에 링크를 따라가게 됩니다.

행의 focus stop은 그 행의 첫 번째 셀에 있습니다. 그럴 수밖에 없습니다. 여기서 행은 위젯이 아니고, Table이 셀들을 배치하며 그 뒤의 띠와 ring을 칠하기 때문에, focus를 쥘 수 있는 것은 셀뿐입니다. 그리고 그 셀이 밝히는 ring은 행 전체입니다.

셀 안의 컨트롤에서 눌린 키는 그 컨트롤의 것입니다. 행의 키는 행의 focus stop 위에 있고, 셀 안의 버튼은 자기 focus stop을 따로 가집니다.

React

empty

행이 없는 표도 제목 줄은 그대로 그리고, 문구는 격자를 가로지르는 셀 하나에격자 아래 가운데에 놓입니다. 기본 문구는 No data이고, empty에는 무엇이든 넣을 수 있습니다.

React

density

셀 여백만 바꿉니다. 같은 size의 두 표는 density가 달라도 타입 스케일이 같습니다.

React

서버 렌더링

PlTable은 라이브러리에서 'use client'가 붙지 않은 유일한 컴포넌트입니다. React Server Component가 표 전체를 렌더링합니다: 시트도, 헤더도, 모든 행도, 컬럼의 render 콜백까지도.

크기를 줄이려는 최적화가 아니라 이 컴포넌트의 API 문제입니다. client 경계에는 함수를 넘길 수 없는데 여기서는 모든 컬럼이 함수입니다. directive가 붙어 있으면 행을 직접 가져오는 페이지가 표를 만들 수 없게 되는데, 그 페이지야말로 표가 있어야 할 자리입니다. directive를 떼면서 치른 값은 하나뿐이고, 그것이 위의 caption입니다.

클라이언트에서 부를 때 달라지는 것은 없습니다. 최상단에 'use client'가 있는 모듈이 PlTable을 import하면 다른 무엇을 import할 때와 똑같이 client component가 되고, 함수인 onRowClick은 어차피 그런 모듈이 있어야 넘길 수 있습니다.

Accessibility

  • <thead>, <tbody>, <th scope="col">, <td>를 갖춘 진짜 <table>을 렌더링합니다. 스크린리더가 각 셀과 함께 열 제목, 행의 위치, 전체 행 수를 읽어 줍니다.
  • caption<caption>이 되고, 이것이 표의 접근 가능한 이름입니다. 한 페이지에 표가 둘 이상이라면 붙일 만합니다.
  • 누를 수 있는 행도 <tr>로 남습니다. 행에 붙인 role="button"은 따로 떼어 놓고 보면 그럴듯하지만 행이라는 의미를 지워 버려서, 그 안의 모든 셀이 자기가 속한 표에서 떨어져 나갑니다.
  • 누를 수 있는 행은 tabIndex={0}을 갖고 EnterSpace에 반응합니다. Space가 페이지를 스크롤하지 않도록 막습니다.
  • 행의 focus ring은 안쪽으로 그려집니다. 시트가 자기 둥근 모서리에서 잘리기 때문에, 첫 행이나 마지막 행 바깥으로 그린 outline은 위나 아래가 잘려 나갑니다.
  • 셀 여백과 정렬, 배경, 테두리를 inline style로 쓰고, <table> 자신의 displaywidth, margin, border-collapse도 마찬가지입니다. 호스트 스타일시트가 table, td, th를 태그 이름으로, utility class가 이길 수 없는 specificity로 지정하기 때문입니다. td { border: 1px solid }는 디자인이 요청한 적 없는 격자선을 그리고, table { display: block }은 격자가 시트를 채우지 못하게 하며, table { margin: 20px 0 }은 판 모서리에 붙어 있어야 할 표를 밀어냅니다. 이 셋을 모두 이기는 것이 inline style입니다.
  • 격자는 진짜 Table이라, 행과 셀이 든 표로 읽히고 스크린리더가 셀 단위로 옮겨 다닐 수 있습니다.
  • 제목 칸은 그 열의 header로 읽힙니다. 아래의 모든 숫자 앞에 열 이름이 붙는 것이 이것 덕분입니다.
  • caption은 시트 맨 위에 그려지고 격자 위의 한 줄로 읽힙니다. 표의 이름이 그려진 것과 달라야 할 때를 위해 semanticLabel이 따로 있습니다.
  • 누를 수 있는 행도 행이라는 의미를 그대로 지킵니다. tap 액션은 셀에 있고, 행을 버튼이라고 부르는 것은 아무것도 없습니다. 버튼으로 읽히는 행은 그 안의 셀들이 자기가 속한 표에서 떨어져 나간 행입니다.
  • 행의 focus stop은 첫 번째 셀에 있고, ring은 행이 안쪽으로 직접 칠합니다. 시트가 자기 둥근 모서리에서 잘리기 때문에, 첫 행이나 마지막 행 바깥에 그린 ring은 위나 아래가 잘려 돌아옵니다.
  • 모든 셀은 그 행에서 가장 큰 셀만큼 높습니다. 그래서 행은 가장 긴 글자 줄에서만이 아니라 자기 전부에서 눌립니다.

React 빌드와 다른 점

ReactFlutter이유
key가 속성을 가리키고 render는 선택cell이 필수Dart에는 임의의 타입에 대한 row[key]가 없습니다. 행을 dynamic으로 넓히느니 접근자를 쓰는 편이 쌉니다.
width: number | stringwidth: doubleflex: double픽셀은 픽셀 그대로, 퍼센트는 남은 폭의 몫이 됩니다. 자기 폭에 맞아떨어져야 하는 표에서 퍼센트란 원래 그것이었습니다.
시트의 overflow-x: auto격자는 시트만큼 넓습니다. 열에 더 넓은 자리가 필요하면 SingleChildScrollView로 감싸세요. 세로 스크롤은 어느 쪽이든 표가 직접 갖고 있습니다.
getRowKeyrowKey같은 일, Flutter의 철자. 돌려주는 것은 React.Key가 아니라 LocalKey입니다.
onRowClickonRowPressed누름이 부르는 것에 대한 이 패키지의 이름입니다.
maxHeight: number | stringmaxHeight: double픽셀은 픽셀 그대로입니다. 받을 CSS 길이가 없습니다.
접근 가능한 이름인 <caption>그려지는 한 줄과 semanticLabelFlutter는 노드에 문자열로 이름을 붙이고, caption은 위젯입니다. 그 문구는 여전히 먼저 읽힙니다.
inline style 우회table, td, th를 다시 스타일링하려 드는 호스트 스타일시트가 없으니, 우회할 것도 없습니다.
className, style전달할 클래스 목록도 style 속성도 없습니다.

Released under the MIT License