table —— テーブル
テーブルはデータ表示の「重器」です:ソート、フィルタ、多段ヘッダー、固定列、仮想スクロール、列幅ドラッグ、それぞれが独立したアルゴリズムの難問です。core/table はこれらをすべて純関数として切り出します——UI 層は結果を DOM にバインドするだけです。このモジュールは本パッケージで最も関数の多いコンポーネントモジュールの一つです。
- 各端末で
LhTableを実装するケース:管理画面のリスト、データレポート、一括操作テーブル。 - 構造化データにソート / フィルタ / 固定列 / 多段ヘッダーが必要なシーン。
- 使うべきでないケース:データ量が極端に少なくインタラクションもない(シンプルな
descriptionsか手書きのテーブルの方が軽い);カード式の情報ストリーム(cardレイアウトを使う)。
インポート方法
Section titled “インポート方法”import { LH_TABLE_ROW_HEIGHT, LH_TABLE_OVERSCAN, LH_TABLE_MIN_COL_WIDTH, computeVisibleRange, cycleSortOrder, sortTableRows, getCellValue, getColumnWidth, getGridTemplate, getStickyOffsets, getTableClassNames, getTableCellClassNames, flattenLeafColumns, buildHeaderCells, getHeaderStickyOffsets, filterTableRows, clampColumnWidth, getTableSortAria,} from '@lh-ui/share/core/table'import type { LhTableAlign, LhTableColumn, LhTableFilterOption, LhTableHeaderCell, LhTableRow, LhTableSortOrder, LhTableVisibleRange, LhTableCellSticky,} from '@lh-ui/share/core/table'null 値は末尾:sortTableRows は null / undefined / NaN を統一的に扱います——これらは通常の比較に参加せず、一律で末尾に置かれ、desc のときも末尾のままです。これは明確なプロダクトの決定です:空データの行が、逆順になったからといって先頭に跳んで有効なデータを遮ってはなりません。
多段ヘッダーの安全性:flattenLeafColumns と buildHeaderCells は祖先チェーンの seen ではなくグローバルな seen を使います——同一の column オブジェクトが DAG 的に共有参照される(同じ列が 2 つのグループの下に現れる)ことがあるためです。グローバルな重複判定によりその列は一度だけ送出され、本体の列数がヘッダーの葉セルの数と一致します。buildHeaderCells の leafSpan が「再帰的に実際に送出された葉の数」を使うのも、共有サブツリーが 2 回数えられて列数がずれるのを防ぐためです。
グループヘッダーの sticky 継承:getHeaderStickyOffsets は「その区間内の葉列がすべて同じ側に fixed」のときだけグループヘッダーに先頭 / 末尾の葉のオフセットを継承させます——固定区と流動区にまたがるグループヘッダーは単一のオフセットで位置を決められないため、固定しないようにします。
clampColumnWidth の同型オーバーロード:数値を入れれば数値、文字列を入れれば文字列が出る——呼び出し側は同型変換のために型アサーションをする必要はありません。NaN はそのまま下限を返します(ドラッグ中に NaN の座標が出ることがあります)。
computeVisibleRange は @deprecated:仮想スクロールのアルゴリズムは共有コア core/shared/virtual の computeRange(VirtualList と同源)に収束しました。この関数は vue2 など下流の既存参照のための互換エイリアスとして残っています。
| 関数 | シグネチャ | 戻り値 | 役割 |
|---|---|---|---|
computeVisibleRange |
(scrollTop, viewportHeight, rowHeight, total, overscan?) => LhTableVisibleRange |
可視区間 | @deprecated、computeRange に委譲 |
cycleSortOrder |
(current?) => LhTableSortOrder | undefined |
次のソート状態 | なし→asc→desc→なし |
sortTableRows |
(rows, prop, order, sorter?) => LhTableRow[] |
新しい配列 | null 値は末尾 |
getCellValue |
(row, column) => string |
表示値 | formatter を優先 |
getColumnWidth |
(column) => string |
'120px' / '1fr' |
幅指定なしは均等分割 |
getGridTemplate |
(columns) => string |
grid テンプレート | CSS grid レイアウト用 |
getStickyOffsets |
(columns) => LhTableCellSticky[] |
左右のオフセット | 固定列の累積 |
getTableClassNames |
(state: { stripe, border, size, virtual }) => string[] |
クラス名 | 4 つの修飾 |
getTableCellClassNames |
(state) => string[] |
クラス名 | 整列 / 固定列 / 幅ドラッグ |
flattenLeafColumns |
(columns) => LhTableColumn[] |
葉列 | 多段ヘッダーの本体用 |
buildHeaderCells |
(columns) => LhTableHeaderCell[] |
フラットなヘッダーセル | rowspan/colspan を含む |
getHeaderStickyOffsets |
(leafColumns, headerCells) => LhTableCellSticky[] |
ヘッダーのオフセット | グループヘッダーの条件付き継承 |
filterTableRows |
(rows, filters) => LhTableRow[] |
新しい配列 | 列間は AND、列内は OR |
clampColumnWidth |
(width: number | string) => number | string |
クランプ後の幅 | 下限 40px |
getTableSortAria |
(options: { sortOrder?, sortable? }) => LhAriaAttrs |
aria 属性バッグ | aria-sort |
定数:LH_TABLE_ROW_HEIGHT = 48(デフォルト行高)、LH_TABLE_OVERSCAN = 3(スクロールのバッファ行数)、LH_TABLE_MIN_COL_WIDTH = 40(幅ドラッグの下限)。
export type LhTableAlign = 'left' | 'center' | 'right'export type LhTableSortOrder = 'asc' | 'desc'export type LhTableRow = Record<string, any>
export interface LhTableColumn { prop: string label: string width?: number | string // 数値は px align?: LhTableAlign fixed?: 'left' | 'right' // width との併用が必要 sortable?: boolean slot?: string // カスタムセルのスロット名 formatter?: (row: LhTableRow, value: any) => string children?: LhTableColumn[] // 設定するだけで多段ヘッダーになる filters?: LhTableFilterOption[] // 葉列でのみ有効 filterMethod?: (value: string | number, row: LhTableRow) => boolean filterMultiple?: boolean // デフォルト true resizable?: boolean // 数値 width の葉列である必要がある}
export interface LhTableHeaderCell { column: LhTableColumn level: number // ヘッダー階層 0 から leafStart: number // 葉列の順序の起点 leafSpan: number // またがる葉列の数 rowspan: number // 最大深さに合わせて補完 colspan: number // = leafSpan isLeaf: boolean}
export interface LhTableVisibleRange { start: number; end: number; offsetY: number; totalHeight: number }export interface LhTableCellSticky { left?: number; right?: number }アクセシビリティ
Section titled “アクセシビリティ”getTableSortAria は aria-sort を出力し、cycleSortOrder の状態機械と厳密に揃えます:
getTableSortAria({ sortable: true, sortOrder: 'asc' }) // { 'aria-sort': 'ascending' }getTableSortAria({ sortable: true, sortOrder: 'desc' }) // { 'aria-sort': 'descending' }getTableSortAria({ sortable: true }) // { 'aria-sort': 'none' }getTableSortAria({ sortable: false }) // {}(ソート不可列は出力しない)3 つ目に注意してください:未ソートのとき出力を省略するのではなく aria-sort="none" を出力します——これは APG の明確な要件で、「この列はソート可能だが現在未ソートである」ことを明示します。スクリーンリーダーはこれに基づいて、この列でソート操作を実行できるとユーザーに伝えます。
その他の規約(UI 層が実装):ヘッダーは <th scope="col">;固定列に tabindex を作って余分なフォーカスを与えない;ページネーション領域は独立した nav;行選択列の checkbox には aria-label が必要です(例:「3 行目を選択」)。
import { cycleSortOrder, sortTableRows, filterTableRows, buildHeaderCells } from '@lh-ui/share/core/table'
cycleSortOrder(undefined) // 'asc'cycleSortOrder('asc') // 'desc'cycleSortOrder('desc') // undefined(ソート解除)
const rows = [ { name: '鈴木', age: 30 }, { name: '佐藤', age: null }, { name: '田中', age: 20 },]sortTableRows(rows, 'age', 'asc')// 田中 20 → 鈴木 30 → 佐藤 null(null 値は常に末尾)sortTableRows(rows, 'age', 'desc')// 鈴木 30 → 田中 20 → 佐藤 null(逆順でも null 値はやはり末尾)sortTableRows(rows, 'age', undefined) // 元の順序のコピー
filterTableRows(rows, [{ column: { prop: 'name' }, values: ['田中'] }])// [{ name: '田中', age: 20 }]filterTableRows(rows, []) // 全件のコピー(有効なフィルタなし)
// 多段ヘッダーconst columns = [ { label: '基本情報', children: [{ prop: 'name', label: '名前' }, { prop: 'age', label: '年齢' }] }, { prop: 'city', label: '都市' },]flattenLeafColumns(columns) // [名前, 年齢, 都市](本体はこれに従ってレンダリング)buildHeaderCells(columns)// 基本情報(level 0, leafStart 0, colspan 2)、名前(rowspan 2 で補完)、// 年齢(rowspan 2)、都市(level 0, rowspan 2)<!-- Vue3 --><div :class="getTableClassNames({ stripe, border, size, virtual })"> <div class="lh-table__wrapper"> <div class="lh-table__header" :style="{ gridTemplateColumns: getGridTemplate(leafColumns) }"> <div v-for="cell in headerCells" :key="`${cell.level}-${cell.leafStart}`" class="lh-table__header-cell" :style="cellStyle(cell)" v-bind="getTableSortAria({ sortOrder: sortState[cell.column.prop], sortable: cell.column.sortable })" @click="onSort(cell.column)" > {{ cell.column.label }} <span v-if="cell.column.sortable" class="lh-table__sort-indicator"> {{ sortState[cell.column.prop] === 'asc' ? '↑' : sortState[cell.column.prop] === 'desc' ? '↓' : '↕' }} </span> </div> </div> <div class="lh-table__body"> <div v-for="row in displayRows" :key="row.id" class="lh-table__row" :style="{ gridTemplateColumns: getGridTemplate(leafColumns) }" > <div v-for="column in leafColumns" :key="column.prop" :class="getTableCellClassNames({ align: column.align ?? 'left', fixed: column.fixed })" > {{ getCellValue(row, column) }} </div> </div> </div> </div> <LhPagination :total="total" v-model:current="current" v-model:page-size="pageSize" /></div>// ソートの配線(受控)function onSort(column: LhTableColumn) { if (!column.sortable) return const next = cycleSortOrder(sortState.value[column.prop]) if (next) sortState.value = { ...sortState.value, [column.prop]: next } else delete sortState.value[column.prop]}
const displayRows = computed(() => filterTableRows( sortTableRows(props.data, sortProp.value, sortOrder.value), activeFilters.value, ),)// React:仮想スクロール// const range = computeRange(scrollTop, viewportHeight, rowHeight, rows.length, 3)// range.start..range.end をレンダリング。外側で totalHeight で高さを確保し、内側を translateY(range.offsetY)// uniapp:<scroll-view scroll-y> + 可視区間の計算;CSS grid は一部の端末でサポートが限定のため// flex 列に退化可;core のソート / フィルタ / ヘッダー展開の関数は端末非依存でそのまま使えるスタイルとトークン
Section titled “スタイルとトークン”| クラス名 | 意味 |
|---|---|
.lh-table / --{small|medium|large} |
基本クラス / サイズの段階(行高とパディング) |
.lh-table--stripe / --border / --virtual |
シマシマ / 枠線 / 仮想スクロール |
.lh-table__header / __header-cell |
ヘッダー |
.lh-table__body / __row |
本体 |
.lh-table__cell / --{left|center|right} |
セル / 整列 |
.lh-table__cell--fixed-left / --fixed-right |
固定列(sticky) |
.lh-table__cell--fixed-left-last / --fixed-right-first |
固定列の境界(投影で区切る) |
.lh-table__cell--resizable |
幅ドラッグ可 |
.lh-table__empty |
空状態 |
デフォルトの行高は 48px(LH_TABLE_ROW_HEIGHT)。仮想スクロールアルゴリズムの rowHeight パラメータと一致していなければならず、そうでないとウィンドウがずれます。ヘッダー背景 --lh-fill-color、枠線 --lh-border-color、hover 行 --lh-fill-color-light。
- 行高の一致性は仮想スクロールの前提:
computeRangeはすべての行が等しい高さであると仮定します;行内のコンテンツが高さを押し上げると(長いテキストの折り返し)仮想スクロールはずれます——高さが不揃いなテーブルではvirtualを有効にしないでください。 - 固定列は
widthの宣言が必須:getStickyOffsetsは数値 px または'Npx'文字列だけをオフセットに換算します;'20%'のような単位は換算できず 0 として累積され、固定列が重なってしまいます。 resizableの前提:数値 width の葉列でなければなりません。ドラッグ後にclampColumnWidthで下限にクランプしてから列設定に書き戻します。- ソートの
propとデータのキー:sortTableRowsはrow[prop]で値を取ります。ネストしたフィールド(user.name)は自動でサポートされません。sorterでカスタマイズしてください。 - フィルタの列内 OR:同じ列の複数の選択値は「または」の関係で、異なる列の間は「かつ」です——これは Element と一致する広く普及したセマンティクスです。
- 多段ヘッダーの本体の列順:本体は
columnsではなくflattenLeafColumns(columns)を使わなければなりません。そうでないと列数がヘッダーの葉セルの数と一致しません。 - DAG 共有列:同一の column オブジェクトを 2 つのグループの下で参照するとき、グローバルな seen が一度だけの送出を保証します——しかし同一オブジェクトを参照する副作用は、一箇所変えれば両方変わることです。「同名で別の設定」が必要なら深いコピーを作ってください。
- 大規模データのソートは O(n log n):毎フレーム数万行をソートするとひっかかります。サーバーサイドソートを検討してください(
sortable: 'custom'は UI 層が約束事を決める)。 getTableSortAriaのaria-sortは<th>に付けます。列のすべてのセルではありません。
@lh-ui/share/core/table → cycleSortOrder( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue