元素

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 { dataTableClasses, dataTableFitPadding, 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.map((column) => ({ ...column, fitPadding: dataTableFitPadding }))}
            classes={dataTableClasses}
            data={rows()}
            rowHeader={false}
            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 提供布局、对齐、虚拟化和交互。应用通过 classes 和 styles 设置各部件的内边距、颜色、边框和选中态外观。Rust 用 nsh_base::data_table() 创建它,TSX 用 nsh/base 中的 <DataTable>。

use nsh_base::{DataTableColumn, data_table};
use nsh_core::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应用的内边距、图标或徽标需要的额外宽度,适配列宽时加到最宽的标题或单元格文字上。
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={{
    header: "bg-secondary",
    headerCell: "pl-2 pr-1",
    headerContent: "gap-1",
    cell: "px-2",
    resizeHandle: "bg-border group-hover:bg-primary",
    sortAscending: "text-muted-foreground opacity-60 selected:text-foreground selected:opacity-100",
    sortDescending: "text-muted-foreground opacity-60 selected:text-foreground selected:opacity-100",
    rowSelected: "bg-primary/10",
    rowHover: "bg-muted/50",
    fixedEdge: "bg-border",
  }}
/>
部件说明
root表格的盒子:边框、圆角和背景
header横跨表格的标题区,包含分组标签行和标题行
groupCell位于所跨各列上方的分组标签
headerCell每一列的标题单元格
headerContent标题文字和排序按钮的布局行,可设置两者的间距
headerCellDragging正在拖动的标题,排在 headerCell 之后
headerCellSorted表格当前排序所依据的列的标题,排在 headerCell 之后
headerCellSelected被选中列的标题,排在 headerCell 之后
sortIndicator可排序列的排序按钮
sortAscending、sortDescending上下两个箭头,当前排序方向应用 selected:
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继承单元格的字体。
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:其他基础组件。