元素

DataTable

DataTable 像电子表格那样显示行和列,并向两个方向滚动,行数和列数都不限。只创建视野中的单元格,所以一百万行、一万列的表格滚动起来与小表格一样流畅。行、列或单元格可以用指针和键盘选择;列有固定的宽度,拖动边缘调整宽度,拖动标题移动,可以在其他列滚动时固定在左侧,也可以排序。标题可以位于分组标签之下,单元格和标题都可以由应用绘制。

el-data-table.tsx
el-data-table.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { DataTable, type DataTableColumn, type DataTableSelection, type DataTableSort } from "nsh/base";
import { Log, Segmented, Split, ui, wide } from "./palette";

const NAMES = ["Ada", "Alan", "Grace", "Linus", "Margaret", "Dennis", "Barbara", "Ken"];
const CITIES = ["Oslo", "Lima", "Kyoto", "Cairo", "Perth", "Quito", "Turin", "Hanoi"];

type Person = { id: number; name: string; city: string; score: number };

const people: Person[] = Array.from({ length: 2000 }, (_, i) => ({
  id: i + 1,
  name: `${NAMES[i % NAMES.length]} ${i + 1}`,
  city: CITIES[(i * 7) % CITIES.length],
  score: (i * 37) % 1000,
}));

const columns: DataTableColumn[] = [
  { key: "id", title: "ID", width: 70, align: "end", fixed: true, sortable: true },
  { key: "name", title: "Name", width: 150, sortable: true },
  { key: "city", title: "City", width: 120, sortable: true },
  { key: "score", title: "Score", width: 90, align: "end", sortable: true },
];

// Sorting is the app's: the table reports the next sort (`onSortChange`),
// the app orders its rows and sets `sort`.
function App() {
  const mode = signal<"row" | "cell">("row");
  const sort = signal<DataTableSort | undefined>(undefined);
  const rows = signal<Person[]>(people);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));

  const sortBy = (next: DataTableSort | undefined) => {
    sort.set(next);
    if (!next) return rows.set(people);
    const key = next.key as keyof Person;
    const sign = next.direction === "descending" ? -1 : 1;
    rows.set([...people].sort((a, b) => (a[key] < b[key] ? -sign : a[key] > b[key] ? sign : 0)));
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>data table</Text>
      <Text style={{ color: ui().muted }}>Press a title to sort, drag its edge to resize or the title to move it</Text>

      <Split
        grow
        width={680}
        inspector={
          <>
            <Segmented label="selection" options={["row", "cell"] as const} value={mode()} onChange={(v) => mode.set(v)} />
            <Log title="events" lines={events()} minHeight={96} />
          </>
        }
      >
        <View style={{ height: wide() ? 360 : 320, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <DataTable
            columns={columns}
            data={rows()}
            rowHeader
            stripe
            cellSelectable={mode() === "cell"}
            sort={sort()}
            onSortChange={(next) => {
              sortBy(next ?? undefined);
              log(`onSortChange: ${next ? `${next.key} ${next.direction}` : "none"}`);
            }}
            onSelectionChange={(s: DataTableSelection | null) => log(`onSelectionChange: ${s ? JSON.stringify(s) : "none"}`)}
            onColumnResize={(key, width) => log(`onColumnResize: ${key} ${Math.round(width)}`)}
            style={{ flex: 1 }}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);

DataTable 负责行为,并带有一套由主题令牌构成的默认外观:标题在 muted 底色上,行与行之间有 border 线,悬停和选中时染上 primary 的色调。每个部件都在这些默认值之上接受应用的样式(classes 和 styles)。Rust 用 nsh_base::data_table() 创建它,TSX 用 nsh/base 中的 <DataTable>。

use nsh_base::{DataTableColumn, data_table};
use nsh_core::ui::prelude::*;

fn column(key: &str, title: &str) -> DataTableColumn {
    let mut column = DataTableColumn::default();
    column.key = key.into();
    column.title = Some(title.into());
    column
}

fn table() -> impl IntoElement {
    data_table()
        .columns(vec![column("name", "Name"), column("city", "City")])
        .flex_1()
}

表格填满应用给它的盒子(flex-1、一个高度),在其中滚动:纵向滚动所有行,横向滚动所有列,带滚动条(scrollbar={false} 隐藏滚动条,bounces={false} 关闭有回弹的平台上的边缘回弹)。

#行和列

data 是行:对象(字段由各列的 key 指定),或按位置被各列索引的数组。数字、布尔值和字符串显示为文本。列是一个 DataTableColumn:

字段说明
key列的标识,在各列中唯一:它的单元格显示的行字段,也是事件和 ref 用来称呼它的名字。
title标题的文字。默认为 key。
width宽度,单位为点。默认 120。
minWidth、maxWidth拖动或适配宽度时的最小和最大宽度。默认 20 和 1200。
fitPadding应用绘制单元格(renderCell)时在文字宽度之外增加的宽度(图标、徽标),适配列宽时计入。
align"start"(默认)、"center" 或 "end"(用于数字),单元格和标题一致。
sortable标题末端有一个排序按钮,按下会请求排序(见排序)。
fixed其他列在它下面滚动时,它固定在左侧。固定列排在其他列之前,保持各自的顺序。
resizable、movable拖动它的边缘是否调整宽度,拖动它的标题是否移动。默认取表格的 resizableColumns 和 movableColumns。
selectable是否参与选择。按钮或复选框所在的列设为 false。默认 true。
cellClass该列单元格的 classes,排在 cell 部件之后:单列的内边距或对齐。
headerClass该列标题的 classes,排在 headerCell 部件之后。
positiveClass该列中表示上涨的单元格的 classes:大于零的数字,或以 + 开头的文本。
negativeClass该列中表示下跌的单元格的 classes:小于零的数字,或以 - 开头的文本。

rowHeight 是表体行的高度(默认 32),headerHeight 是标题行的高度(默认与行高相同)。rowHeader 在左侧添加一列行号,它可以选择行。stripe 让奇数行使用 rowOdd 部件。

#分组标签

groups 在列的上方放置标签,从最低一层开始。每一层是一个 { label, span } 列表,各 span 之和等于列数(或下一层的项数)。

<DataTable
  columns={columns}
  data={rows}
  groups={[
    [{ label: "Person", span: 2 }, { label: "Result", span: 1 }],
  ]}
  class="flex-1"
/>

#任意数量的行

表格按位置请求行,由应用提供。rowCount 是行的总数,data 是应用持有的行,dataStart 是其中第一行的位置。data 之外的行在应用提供之前是空的。onVisibleRowsChange 在每个滚动步骤报告视野中的行 { start, end },是获取其周围一页数据的地方。

const ROWS = 1_000_000;
const page = signal({ start: 0, rows: load(0) });

<DataTable
  columns={columns}
  data={page().rows}
  dataStart={page().start}
  rowCount={ROWS}
  onVisibleRowsChange={({ start, end }) => {
    // Keep the rows in view inside the page held.
    const held = page();
    if (start < held.start || end > held.start + held.rows.length) {
      const first = Math.max(0, start - 200);
      page.set({ start: first, rows: load(first) });
    }
  }}
  class="flex-1"
/>

对于在末尾不断增长的列表,设置 hasMore:当视野中的行进入最后一行的 loadMoreThreshold(默认 20)范围内时,onLoadMore 会针对每个行数触发一次。loading 为 true 时它会等待,loadingView 插槽显示在行的上方。没有行时显示 empty 插槽。

内容超过几百万点后,滚动条跨越的是缩放后的范围,所以一百万行仍然可以滚动到每一行。

#选择

与 gpui-component 一样,由三个开关决定按下会选择什么:

属性按下选择selection
rowSelectable(默认 true)一行:它的任一单元格或行号{ row }
columnSelectable(默认 true)一列:它的标题{ column }
cellSelectable(默认 false)一个单元格;行号列(开启时默认显示)选择行{ row, column }

开启 cellSelectable 并隐藏行号列(rowHeader={false})时,再次按下已选中的单元格会选中它所在的行。按住 Shift 或平台的命令键按下时,只有 selectOnModifierPress(默认 true)为真才会选中行,这样自己维护多行选择的应用可以自行处理这些按下:onRowPress 报告表体的每一次按下,带有所在行、指针下的列、连续点击次数和按住的修饰键。selectable 为 false 的列,它本身和它的单元格都不会被选中。

行从数据中的 0 开始计数,列是它在 columns 中的索引,不受用户移动后顺序的影响。onSelectionChange 报告用户的选择(没有选择时为 null)。selection 属性由应用设置选择;不设置时由表格自己保存。onDoublePress 报告一行、一个单元格或一个标题被连按两次,onContextMenu 报告次要按下或长按,并给出应用打开 Menu 的位置。两者都指出指针下的单元格(row 和 column)、行号(只有 row)或标题(只有 column)。在下一次按下之前,请求菜单的单元格带有 cellActive 部件;单元格不可选时,则是它所在的行带有 rowActive 部件。

屏幕阅读器得到一个网格(名称来自 accessibilityLabel),由列标题和单元格组成,被选中的会读出为已选中。

#键盘

按下表格,或用 Tab 移到表格上(macOS、Web),会让它获得键盘,直到再次按下别处、焦点移走或调用 blur();应用可以用 focus() 让它获得键盘。整张表格是 Tab 的一个停靠点:在表格里 Tab 移动选择,Escape 交出键盘。

按键作用
方向键按行、列或单元格移动选择,并滚动到视野中。
Tab、Shift+Tab移动到下一列或上一列。
Page Up、Page Down按页移动行。
Home、End移动到第一个或最后一个。
Escape先清除选择,再交出键盘。
复制快捷键把选中内容的文字放到剪贴板(copy())。

loopSelection(默认 true)让方向键在两端循环。

#排序

sortable 的列在标题末端有一个排序按钮:一个小的 ghost 按钮,该列未排序时显示为半透明。按下它会按“未排序、升序、降序、再回到未排序”的顺序用 onSortChange({ key, direction }) 报告下一个排序(null 表示清除)。表格不会重新排列行:应用自己对数据排序,或向服务器请求新顺序的行,并设置 sort 让标题显示出来。由于顺序归应用所有,对应用从未持有的一百万行同样适用。

<DataTable
  columns={columns}
  data={rows()}
  sort={sort()}
  onSortChange={(next) => {
    sort.set(next ?? undefined);
    rows.set(sorted(people, next));
  }}
  class="flex-1"
/>

#调整和移动列

拖动列的尾部边缘会在 minWidth 和 maxWidth 之内调整宽度,onColumnResize(key, width) 报告每一步。连续按两次边缘会把该列适配到最宽的文本和标题。拖动标题会移动列,并用一条线显示将放下的位置;onColumnMove 报告 { key, from, to, order },其中 order 是所有列当前顺序的 key。固定列只能在固定列之间移动。resizableColumns 和 movableColumns 对整个表格开关这两项,列自己的 resizable 和 movable 优先。

#实时数据

行情表每秒会变化多次。应用只格式化视野中的行及其周围一段范围(这些行由 onVisibleRowsChange 告知),并把它们作为从 dataStart 开始的 data 交给表格,所以一次更新的开销取决于屏幕上的内容,与总行数无关。涨跌颜色来自列的 positiveClass 和 negativeClass,由原生代码应用到值大于或小于零(或文本以 +、- 开头)的单元格。

const tone = { positiveClass: "text-green-600", negativeClass: "text-red-600" };
const columns: DataTableColumn[] = [
  { key: "symbol", title: "Symbol", fixed: true },
  { key: "price", title: "Price", align: "end", sortable: true },
  { key: "change", title: "Chg%", align: "end", ...tone },
];

行和列是表格创建一次并一直保留的槽位:滚动只是让它们显示别的行和列,所以无论滚动多远,变化的只有文字和位置。

#绘制单元格和标题

当某一列不只是文字时(徽标、按钮),用 renderCell 绘制应用自己的单元格。它只为视野中的单元格调用,参数是 DataTableCell:row、column、key、text(表格原本会显示的文字)和 selected。应用按 row 查找自己的数据。renderHeaderCell 绘制铺满单元格的标题,参数是 DataTableHeaderCell:column、key、title、sort(方向,或无)和 selected。

<DataTable
  columns={columns}
  data={rows()}
  renderCell={(cell) =>
    cell.key === "status" ? (
      <View class={cell.text === "open" ? "rounded bg-green-500/20 px-2" : "rounded bg-muted px-2"}>
        <Text class="text-xs">{cell.text}</Text>
      </View>
    ) : (
      <Text class="px-2 text-sm">{cell.text}</Text>
    )
  }
  class="flex-1"
/>

fontFamily 和 fontSize(默认 14)设置单元格的字体,表格也用它来测量文字以适配列宽。

#Ref

ref 提供 DataTableRef:

方法说明
scrollToRow(row, animated?)把一行滚动到视野中。
scrollToColumn(key, animated?)把一列滚动到视野中。
selection()当前的选择,没有时为 null。
select(selection)选择(null 表示不选)并滚动到视野中,不触发 onSelectionChange。
visibleRows()、visibleColumns()视野中的行和滚动列,形如 { start, end }(不含 end)。
columnWidths()当前的宽度,按 columns 的顺序。
setColumnWidth(key, width)在限制范围内设置列宽。
fitColumn(key)把列设为其最宽文字和标题所需的宽度,范围是视野中的行及其周围最多一千行。返回宽度,没有这一列时返回 null。
dump(start?, end?)给定行(默认全部已持有的行)和标题的文字,与显示一致:{ headers, rows },用于导出。
copy()把选中内容的文字放到剪贴板:一行的单元格以制表符分隔,一列的以换行分隔,一个单元格就是它的文字。
focus()、blur()让表格获得或交出键盘。
menuTarget()、clearMenuTarget()上一次请求上下文菜单的位置({ row?, column? },由 cellActive / rowActive 标记),以及在应用的菜单关闭时清除该标记。

#为部件设置样式

class 和 style 设置表格盒子的样式。classes 按部件接受 Tailwind 类,styles 按部件接受内联样式;部件的类排在它自己的默认样式之后,所以优先生效,而它的 styles 优先于类。

<DataTable
  columns={columns}
  data={rows()}
  stripe
  class="flex-1 rounded-lg border border-border"
  classes={{
    headerCell: "bg-secondary",
    rowSelected: "bg-primary/10",
    rowHover: "bg-muted/50",
    fixedEdge: "bg-border",
  }}
/>
部件说明
root表格的盒子:边框、圆角和背景
header横跨表格的标题区,包含分组标签行和标题行
groupCell位于所跨各列上方的分组标签
headerCell每一列的标题单元格
headerCellSorted表格当前排序所依据的列的标题,排在 headerCell 之后
headerCellSelected被选中列的标题,排在 headerCell 之后
sortIndicator可排序列的排序按钮:上下两个箭头,已排序方向的箭头使用文字颜色
resizeHandle列尾部边缘供拖动调整的线
moveIndicator被拖动的列将放下的位置上的线
row每一个表体行
rowOdd奇数行,排在 row 之后(stripe)
rowHover指针所在的行,排在 row 之后
rowSelected被选中的行,排在 row 之后
rowActive单元格不可选时请求过上下文菜单的行,直到下一次按下,排在 row 之后
cell每一个表体单元格
cellSelected被选中的单元格,排在 cell 之后
cellActive请求过上下文菜单的单元格,直到下一次按下,排在 cell 之后
cellColumnSelected被选中列的单元格,排在 cell 之后
rowHeader行号单元格
rowHeaderCorner行号上方的角落
fixedEdge固定列尾部边缘的线,位于在其下滚动的列之上
emptyempty 插槽的盒子

#属性

属性Rust类型默认值说明
columns.columns(..)DataTableColumn[][]从左到右的列。固定列排在前面。
data.data(..)Row[][]行:按列 key 取字段的对象,或按位置索引的数组。
rowCount.row_count(..)number已持有的行数行的总数。
dataStart.data_start(..)number0data 第一行对应的行号。
rowClasses.row_classes(..)string[][]所给各行的类名,按它们在 data 中的位置对应:行自己的外观,不需要每行调用 JS。
rowHeight.row_height(..)number32表体行的高度,单位为点。
headerHeight.header_height(..)number行高标题行的高度。
groups.groups(..)DataTableColumnGroup[][][]列上方的标签,从最低一层开始。
sort.sort(..)DataTableSort数据的排序依据,显示在标题上。
rowSelectable.row_selectable(..)booleantrue按下和按键可以选择行。
columnSelectable.column_selectable(..)booleantrue按下标题会选中该列,左右键在列之间移动。
cellSelectable.cell_selectable(..)booleanfalse按下和按键可以选择单元格。
selectOnModifierPress.select_on_modifier_press(..)booleantrue按住 Shift 或命令键按下时选中行。
selection.selection(..)DataTableSelection当前选择,由应用设置。
loopSelection.loop_selection(..)booleantrue方向键在两端循环。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对表格的称呼。
rowHeader.row_header(..)boolean开启 cellSelectable 时显示左侧的一列行号。
stripe.stripe(..)booleanfalse奇数行使用 rowOdd 部件。
resizableColumns.resizable_columns(..)booleantrue拖动列的边缘调整宽度。
movableColumns.movable_columns(..)booleantrue拖动标题移动列。
loading.loading(..)booleanfalse表格正在加载:onLoadMore 等待,loadingView 显示。
hasMore.has_more(..)booleanfalse还有更多行可以加载。
loadMoreThreshold.load_more_threshold(..)number20距最后一行多少行时触发 onLoadMore。
fontFamily.font_family(..)string主题的 font-sans单元格的字体。
fontSize.font_size(..)number14单元格的字号,单位为点。
scrollbar.scrollbar(..)booleantrue滚动条。
verticalScrollbar、horizontalScrollbar.vertical_scrollbar(..)、.horizontal_scrollbar(..)booleanscrollbar单个方向的滚动条(水平的是桌面平台表格下方的滚动条)。
bounces.bounces(..)booleantrue滚动到边缘时是否回弹(平台支持时)。
renderCell.render_cell(..)(cell: DataTableCell) => JSX.Element绘制视野中的单元格。
renderHeaderCell.render_header_cell(..)(cell: DataTableHeaderCell) => JSX.Element绘制标题。
empty.empty(..)JSX.Element没有行时显示的内容。
loadingView.loading_view(..)JSX.Elementloading 时显示在行上方的内容。
classes.classes(..)DataTableClasses{}按部件设置的 Tailwind 类。
styles.styles(..)DataTableStyles{}按部件设置的内联样式,优先于类。
class、style样式方法string、StyleProp<ViewStyle>表格的盒子。

#事件

事件Rust说明
onSelectionChange.on_selection_change(|selection: &Option<DataTableSelection>| ..)用户改变了选择:{ row?, column? },或 null。
onRowPress.on_row_press(|event: &DataTablePress| ..)按下了一行或一个单元格:{ row, column?, clicks, shift, control, alt, platform }。
onDoublePress.on_double_press(|target: &DataTableTarget| ..)一行、一个单元格或一个标题被连按两次:{ row?, column? }。
onContextMenu.on_context_menu(|event: &DataTableContextMenu| ..)请求了上下文菜单:{ row?, column?, x, y },位置位于根视图中。
onSortChange.on_sort_change(|sort: &Option<DataTableSort>| ..)按下了排序按钮:下一个排序 { key, direction },或 null。
onColumnResize.on_column_resize(|key: &SharedString, width: &Points| ..)列被调整宽度,拖动的每一步都会触发,适配设置宽度时再触发一次。
onColumnMove.on_column_move(|event: &DataTableColumnMove| ..)列被移动:{ key, from, to, order }。
onVisibleRowsChange.on_visible_rows_change(|range: &DataTableRange| ..)视野中的行变了:{ start, end }。在这里获取行。
onVisibleColumnsChange.on_visible_columns_change(|range: &DataTableRange| ..)视野中的滚动列变了。
onLoadMore.on_load_more(|| ..)在 hasMore 且未 loading 时,视野中的行进入了最后一行的 loadMoreThreshold 范围内。

#相关

  • Editor 和 DiffView:另外两个处理大量数据的基础组件。
  • Menu:从 onContextMenu 打开的菜单。
  • nsh/base:其他基础组件。