Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

DataTable

DataTable показывает строки и столбцы как электронная таблица и прокручивается в обе стороны при любом их числе. Создаются только видимые ячейки, поэтому таблица из миллиона строк и десяти тысяч столбцов прокручивается так же, как маленькая. Выделять можно строки, столбцы или ячейки, указателем и с клавиатуры. У столбцов фиксированная ширина, их размер меняется перетаскиванием края, порядок меняется перетаскиванием заголовка, они могут оставаться слева, пока остальные прокручиваются, и сортироваться. Заголовки можно разместить под подписями групп, а ячейку или заголовок может отрисовать приложение.

el-data-table.tsx
el-data-table.tsxLive
import { mount, signal, Text, View } from "nsh";
import { DataTable, type DataTableColumn, 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 }}>
          <DataTable
            columns={columns.map((column) => ({ ...column, fitPadding: dataTableFitPadding }))}
            classes={dataTableClasses}
            data={rows()}
            rowHeader={false}
            stripe
            cellSelectable={mode() === "cell"}
            sort={sort()}
            onSortChange={({ sort: next }) => {
              sortBy(next ?? undefined);
              log(`onSortChange: ${next ? `${next.key} ${next.direction}` : "none"}`);
            }}
            onSelectionChange={({ selection }) => log(`onSelectionChange: ${selection ? JSON.stringify(selection) : "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 через <DataTable> из nsh/base.

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 столбцов, или массивы, которые столбцы индексируют по позиции. Числа, булевы значения и строки показываются как текст. В Rust строки передаются по одному полю за раз, каждое одного типа: DataTableRows::new(2).texts("symbol", ["AAPL", "MSFT"]).numbers("change", [1.2, -0.4]). Столбец это 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Классы ячеек столбца, после классов части cell: отступы или выравнивание одного столбца.
headerClassКлассы заголовка столбца, после классов части headerCell.
positiveClassКлассы ячеек столбца со значением-ростом: число больше нуля или текст, начинающийся с +.
negativeClassКлассы ячеек столбца со значением-падением: число меньше нуля или текст, начинающийся с -.

rowHeight это высота строки тела (по умолчанию 32), а headerHeight высота строки заголовка (по умолчанию равна высоте строки). rowHeader добавляет слева столбец номеров строк, который выделяет строки. stripe применяет к нечётным строкам часть rowOdd.

#Подписи групп

groups размещает подписи над столбцами, начиная с нижнего уровня. Каждый уровень это список { label, span }, сумма охватов которых равна числу столбцов или числу элементов нижнего уровня.

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

#Любое число строк

Таблица запрашивает строки по позиции, а приложение их отдаёт. rowCount это общее число строк, data строки, которые есть у приложения, а dataStart позиция первой из них. Строки вне data пусты, пока приложение их не предоставит. onVisibleRowsChange сообщает о строках в области видимости, { range: { 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={({ range: { 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: onLoadMore срабатывает один раз для каждого числа строк, когда видимые строки подходят к последней ближе чем на loadMoreThreshold (по умолчанию 20). Пока 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, веб) отдаёт ей клавиатуру до следующего нажатия, перехода фокуса в другое место или blur(); focus() отдаёт клавиатуру таблице из приложения. Таблица занимает одну остановку Tab: внутри неё Tab перемещает выделение, а Escape возвращает клавиатуру.

КлавишиЧто делают
СтрелкиПеремещают выделение на строку, столбец или ячейку, прокручивая его в область видимости.
Tab, Shift+TabПереходят к следующему или предыдущему столбцу.
Page Up, Page DownПеремещают на страницу строк.
Home, EndПереходят к первому или последнему.
EscapeСнимает выделение, затем возвращает клавиатуру.
Сочетание копированияПомещает текст выделения в буфер обмена (copy()).

loopSelection (по умолчанию true) заставляет стрелки переходить через края.

#Сортировка

У столбца sortable в конце заголовка есть кнопка сортировки, небольшая ghost-кнопка, приглушённая, пока столбец не отсортирован. Нажатие сообщает следующую сортировку ({ key, direction }) по циклу: без сортировки, по возрастанию, по убыванию и снова без сортировки, с onSortChange({ sort }) (null, чтобы сбросить). Таблица сама не переупорядочивает строки: приложение сортирует свои данные или просит у сервера строки в новом порядке и задаёт sort, чтобы заголовок это показал. Поскольку порядок принадлежит приложению, сортировка работает одинаково и для миллиона строк, которых у приложения нет.

<DataTable
  columns={columns}
  data={rows()}
  sort={sort()}
  onSortChange={({ sort: 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) и передаёт их как data из dataStart, так что обновление стоит ровно столько, сколько занимает то, что на экране, при любом числе строк. Цвета роста и падения берутся из 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. Метод, которому передали строку, столбец или ключ столбца, которых нет в таблице, ничего не меняет и пишет в лог предупреждение с названием метода и значением; fitColumn завершается отказом с TypeError для неизвестного ключа.

МетодОписание
scrollToRow(row, animated?)Прокручивает строку в область видимости.
scrollToColumn(key, animated?)Прокручивает столбец в область видимости.
selection()Что выделено, null, если ничего.
select(selection)Выделяет (null: ничего) и показывает в области видимости, без onSelectionChange.
visibleRows(), visibleColumns()Строки и прокручиваемые столбцы в области видимости, как { start, end } (end не входят).
columnWidths()Текущие ширины в порядке columns.
setColumnWidth(key, width)Задаёт ширину столбца в его пределах.
fitColumn(key)Задаёт столбцу ширину, нужную его самому широкому тексту и заголовку среди видимых строк и до тысячи дополнительных вокруг них. Возвращает ширину.
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Линия на заднем краю фиксированных столбцов, над столбцами, которые прокручиваются под ними
emptyКоробка слота empty

#Props

PropRustTypeПо умолчаниюОписание
columns.columns(..)DataTableColumn[][]Столбцы слева направо. Закреплённые идут первыми.
data.data(..)Row[][]Строки: объекты по столбцу key или массивы по позиции.
rowCount.row_count(..)numberзагруженные строкиОбщее количество строк.
dataStart.data_start(..)number0Индекс первой строки в data.
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 или Command выбирает строку.
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.ElementЧто отображается поверх строк, пока loading.
classes.classes(..)DataTableClasses{}Классы Tailwind по частям.
styles.styles(..)DataTableStyles{}Встроенные стили по частям, поверх классов.
class, stylestyle methodsstring, StyleProp<ViewStyle>Контейнер таблицы.

#Events

EventRustОписание
onSelectionChange.on_selection_change(|selection: &Option<DataTableSelection>| ..)Пользователь изменил выбор: { selection }, { row?, column? } или null.
onRowPress.on_row_press(|event: &DataTablePress| ..)Нажата строка или ячейка: { row, column?, clicks, shift, control, alt, platform }.
onDoublePress.on_double_press(|target: &DataTableTarget| ..)Строка, ячейка или заголовок нажаты дважды: { target }, { row?, column? }.
onContextMenu.on_context_menu(|event: &DataTableContextMenu| ..)Запрошено контекстное меню: { row?, column?, x, y }, точка в корневом представлении.
onSortChange.on_sort_change(|sort: &Option<DataTableSort>| ..)Нажата кнопка сортировки: { sort }, следующая сортировка { key, direction } или null.
onColumnResize.on_column_resize(|key: &SharedString, width: &Points| ..)Изменена ширина столбца ({ key, width }) на каждом шаге перетаскивания и ещё раз, когда её задаёт подгонка.
onColumnMove.on_column_move(|event: &DataTableColumnMove| ..)Столбец перемещён: { key, from, to, order }.
onVisibleRowsChange.on_visible_rows_change(|range: &DataTableRange| ..)Изменились видимые строки: { range }, { start, end }. Загружайте строки здесь.
onVisibleColumnsChange.on_visible_columns_change(|range: &DataTableRange| ..)Изменились видимые прокручиваемые столбцы: { range }.
onLoadMore.on_load_more(|| ..)Видимые строки оказались в пределах loadMoreThreshold от последней, пока hasMore и не loading.
  • Editor и DiffView: другие базовые компоненты для работы с большими объёмами данных.
  • Menu: меню, которое открывается из onContextMenu.
  • nsh/base: другие базовые компоненты.