表格(Table)是一種專門用來展示結構化資料的元件,它可以幫助使用者以易於掃描的方式瀏覽資料,使他們能夠快速辨別資料中的模式並進行分析。
除了展示資料,表格元件還可以包含以下功能:
在開始實作前,我們先研究各個 UI Library 的 Table 元件是如何設計的。
Element Plus
<template>
<ElTable :data="tableData" style="width: 100%">
<ElTableColumn prop="date" label="Date" width="180" sortable />
<ElTableColumn prop="name" label="Name" width="180" />
<ElTableColumn prop="address" label="Address" />
</ElTable>
</template>
Element Plus 的 <ElTable>
透過 data
傳入表格的資料,它必須是一個陣列。而表格的每個欄位(Columns)則使用 <ElTableColumn>
定義。
<ElTableColumn>
可以說是整個表格設定的核心,功能非常豐富,我們無法全部列舉。常用的設定像是 prop
可以用來設定對應到 data
中每個物件的指定屬性,而 label
則定義該欄位的標題。其他比較重要的設定包括 align
,用於設定欄位的對齊方式;sortable
,可以定義欄位是否可以排序;formatter
,可以定義欄位的格式化方式。
Vuetify
在 Vuetify 中,如果只需要最簡單的表格功能,可以選用 <VTable>
;比較複雜的功能可依照使用情境選用 <VDataTable>
、<VDataTableServer>
與 <VDataTableVirtual>
。
<template>
<VDataTable
:headers="headers"
:items="plants"
item-key="name"
/>
</template>
Vuetify 的 <VDataTable>
可以使用 headers
來定義表格的欄位,而 items
是表格的資料。item-key
則是用來指定資料中的唯一值。
headers
的功能與 Element Plus 的 <ElTableColumn>
類似,如果將 Element Plus 範例中的 <ElTableColumn>
轉換成 headers
的設定,會變成以下的樣子:
const headers = [
{ text: 'Date', key: 'date', width: '180', sortable: true },
{ text: 'Name', key: 'name', width: '180' },
{ text: 'Address', key: 'address' }
]
另外,itemKey
的設計讓開發人員可以依照專案需求調整資料為唯一值的欄位,像是 id
、uuid
或 ulid
都是很常見的唯一值。
Nuxt UI
<template>
<UTable :columns="columns" :rows="people" />
</template>
Nuxt UI 的 <UTable>
接受 columns
設定每個欄位的標題與屬性,而 rows
則是表格的資料。
Nuxt UI 的 columns
相較於 Element Plus 與 Vuetify 更為簡單,也都是我們最常見的設定。
label
- 定義欄位的標題。key
- 定義對應到 rows
中的屬性。sortable
- 定義欄位是否可以排序。direction
- 第一次點選時的排序方向。class
- 每個單元格(Cell)的 class。rowClass
- 整個欄位(Column)的 class。sort
- 排序 function 的設定。綜合以上並結合自身經驗,我們統整出 <AtomicTable>
的功能:
columns
定義表格的欄位。items
定義表格的資料。itemKey
定義資料中的唯一值。sort
定義排序的方式。bodyRowClass
定義每個 Row 的 class。bodyCellClass
定義每個單元格的 class。而 columns
可以定義的屬性如下:
key
:定義對應到 items
中的屬性。label
:定義欄位的標題。width
:欄位寬度。align
:欄位對齊方式。class
:每個欄位的 class。render
:定義欄位的格式化方式。使用結構如下:
<template>
<AtomicTable
:columns="columns"
:items="items"
itemKey="id"
:sort="sort"
bodyRowClass="table-row"
bodyCellClass="table-cell"
/>
</template>
首先,我們將需求中提到的功能整理成 props
與 emit
的介面,我們會需要下列屬性:
Props
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
columns | Array<{ key: string, label: string, ... }> |
[] | 定義表格的欄位 |
items | Array<{ [key: string]: any }> |
[] | 定義表格的資料 |
itemKey | string |
'id' | 定義資料的唯一值 |
sort | { column: string, direction: 'asc' | 'desc' } |
undefined | 定義排序方式 |
bodyRowClass | any |
'' | 定義每個 Row 的 class |
bodyCellClass | any |
'' | 定義每個 Cell 的 class |
Emits
名稱 | 參數 | 說明 |
---|---|---|
update:sort | { column: string, direction: 'asc' | 'desc' } | 更新排序方式 |
type TableItem = Record<string, any>;
interface TableColumn {
key: string;
sortable?: boolean;
label?: string;
width?: number | string;
align?: 'left' | 'center' | 'right';
class?: any;
headCellClass?: any;
bodyCellClass?: any;
render?(value: any, index: number, item: TableItem): any;
}
interface AtomicTableProps {
columns: TableColumn[];
items?: TableItem[];
itemKey?: string;
sort?: {
column?: string | undefined;
direction?: 'asc' | 'desc' | undefined;
};
headRowClass?: any;
bodyRowClass?: any;
bodyCellClass?: any;
}
interface AtomicTableEmits {
(event: 'update:sort', value: AtomicTableProps['sort']): void;
}
const props = withDefaults(defineProps<AtomicTableProps>(), {
columns: () => [],
items: () => [],
itemKey: 'id',
sort: undefined,
headRowClass: undefined,
bodyRowClass: undefined,
bodyCellClass: undefined,
});
const emit = defineEmits<AtomicTableEmits>();
我們先做出一個基本的表格,並且讓表格撐滿畫面。
<template>
<div class="atomic-table">
<table class="atomic-table__table">
<thead class="atomic-table__thead">
<tr class="atomic-table__row">
<th
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody class="atomic-table__body">
<tr
v-for="item in items"
:key="item[itemKey]"
class="atomic-table__row"
>
<td
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
>
{{ item[column.key] }}
</td>
</tr>
</tbody>
</table>
<div>
</template>
.atomic-table {
&__table {
min-width: 100%;
}
}
接下來,我們處理 columns
中的各種設定,像是 width
、align
與 class
。
在表格中,如果要定義每個欄位的 width
,我們可以利用 <col>
來定義。
const isNumberish = (value: unknown): value is number | `${number}` => {
return isNumber(value) || (isString(value) && !isNaN(Number(value)));
}
const toUnit = (value: number | string, unit = 'px') => {
if (isNumberish(value)) {
return `${value}${unit}`;
} else if (isString(value)) {
return value;
}
}
<colgroup>
<col
v-for="column in columns"
:key="column.key"
:style="column.width
? { width: toUnit(column.width) }
: undefined
"
>
</colgroup>
例如範例中的 Address 欄位我們設定寬度為 40%
,效果就會像是這樣:
<col>
很好用,但能透過 CSS 設定的屬性有限,只有像是:background
、border
、visibility
與 width
等。
<col>
元素上接受width
、align
等屬性,但這些屬性在 HTML5 中已經被棄用。<col width="40%" align="left">
接著我們將 align
與 class
綁定到 <th>
與 <td>
上。
<th
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
:class="[
`atomic-table__cell--${column.align || 'left'}`,
column.class,
column.headCellClass,
]"
>
{{ column.label }}
</th>
<td
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
:class="[
`atomic-table__cell--${column.align || 'left'}`,
column.class,
column.bodyCellClass,
]"
>
{{ item[column.key] }}
</td>
我們可以讓 bodyCellClass
的功能再更彈性,例如使用者可以依照資料的不同來顯示不同的樣式。所以這裡我們允許 bodyCellClass
傳入一個 function,這個 function 可以接受到當下欄位的資料、第幾個 Row 與整個 Row 的資料。
interface TableColumn {
bodyCellClass?:
| string
| any[]
| Record<string, any>
| ((key: string, index: number, value: any, item: TableItem) => any);
}
我們判斷使用者傳入的 bodyCellClass
是否為 function,如果是的話我們把需要的資料帶進去。
<td
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
:class="[
`atomic-table__cell--${column.align || 'left'}`,
column.class,
isFunction(column.bodyCellClass)
? column.bodyCellClass(item[column.key], index, item)
: column.bodyCellClass,
]"
>
{{ item[column.key] }}
</td>
這樣開發人員在使用時就可以依照資料的不同來顯示不同的樣式。
const columns: TableColumn[] = [
{
key: 'age',
label: 'Age',
align: 'left',
bodyCellClass: (value) => value > 30 ? 'text-blue-500' : '',
sortable: true,
render: (value) => `${value} years old`,
},
]
同樣的作法我們可以套用到 render
上。
<td
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
>
{{ isFunction(column.render)
? column.render(item[column.key], index, item)
: item[column.key]
}}
</td>
這樣我們也可以針對每個欄位的資料做格式化。
const columns: TableColumn[] = [
{
key: 'age',
label: 'Age',
align: 'left',
bodyCellClass: (value) => value > 30 ? 'text-blue-500' : '',
sortable: true,
render: (value) => `${value} years old`,
},
]
如果可以,我們可以在每個欄位加上 Slot,如果遇到需要更複雜的需求時,我們可以讓使用者自行定義欄位的內容。
<td
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
>
<slot
:index="index"
:item="item"
:name="`column:${column.key}`"
:value="item[column.key]"
>
{{ isFunction(column.render)
? column.render(item[column.key], index, item)
: item[column.key]
}}
</slot>
</td>
這裡用了動態的 Slot Name 處理,這樣開發人員就可以針對特定欄位做替換。
<AtomicTable :columns="columns" :items="items">
<template #column:name="{ value }">
<strong>
{{ value.toUpperCase() }}
</strong>
</template>
</AtomicTable>
接著我們實作排序的功能,僅當欄位的 sortable
與 sort
皆有啟用排序按鈕才會顯示。
<th
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
>
<span class="atomic-table__content">
<span>
{{ column.label }}
</span>
<button
v-if="sort && column.sortable"
type="button"
@click="onSort(column.key)"
>
<SortIcon />
</button>
</span>
</th>
排序功能與邏輯在許多 UI Library 都會被內建在元件裡面。這樣雖然很方便,但限制了一些彈性,例如有時候我們改變了排序會跟後端重新取得一包資料,而不是依照現有資料重新排序。我們這裡實作只改變狀態,而不主動幫使用者排序。
我們只處理排序資料的改變,排序資料變化規則如下:
asc
)。asc
)-> 遞減(desc
)-> 不排序 -> 遞增。const NEXT_DIRECTION = {
asc: 'desc',
desc: undefined,
undefined: 'asc',
} as const;
const onSort = (key: string) => {
if (!props.sort) return;
const { column, direction: dir } = props.sort;
const direction = column === key ? NEXT_DIRECTION[`${dir}`] : 'asc';
emit('update:sort', {
column: key,
direction,
});
};
接著,開發人員就可以依照需求來處理排序的邏輯。
最後再加上一些樣式看起來就完成了簡單的 <AtomicTable>
。
如果專案中經常需要讓使用者選取多個 Row 後進行操作,我們可以加入多選的功能,讓開發人員不需要自己定義欄位並且撰寫選取的邏輯。
我們在 props 上加上 selected
來定義選取的資料,並且在 emits 上加上 update:selected
來更新選取的資料。
interface AtomicTableProps {
// ...
selected?: TableItem[] | Set<TableItem>;
}
interface AtomicTableEmits {
// ...
(event: 'update:selected', value: TableItem[] | Set<TableItem>): void;
}
我們會使用 <input type="checkbox">
來實作,在 Vue 3 中不僅支援陣列也支援 Set,所以我們可以使用 Set 來儲存選取的資料。
我們可以依照 selected
是否不為 undefined
來判定使用者是否需要多選功能。
const isSelectable = computed(() => {
const { selected } = props;
return Array.isArray(selected) || isSet(selected);
});
接著,我們需要準備三筆資料:
isChecked
:判斷是否所有 Row 都被選取。
const isChecked = computed({
get() {
return isIndeterminate.value || size(props.selected) === props.items.length;
},
set(value) {
if (!selectedWritable.value) return;
if (Array.isArray(selectedWritable.value)) {
selectedWritable.value = value ? props.items.slice() : [];
} else {
selectedWritable.value = new Set(value ? props.items : []);
}
},
});
const size = (value: any[] | Set<any> | undefined) => {
return value
? Array.isArray(value)
? value.length
: isSet(value)
? value.size
: 0
: 0;
};
isIndeterminate
:判斷是否有部分 Row 被選取。
const isIndeterminate = computed(() => {
const { selected, items } = props;
const count = size(selected);
return count > 0 && count < items.length;
});
selectedWritable
:選取的資料。
const selectedWritable = computed({
get() {
return props.selected;
},
set(value) {
value && emit('update:selected', value);
},
});
接著我們就把一一加入到表格中。
<colgroup>
<col
v-if="isSelectable"
:style="{ width: '42px' }"
>
<!-- 其他欄位 -->
</colgroup>
<thead class="atomic-table__row">
<th
v-if="isSelectable"
class="atomic-table__cell atomic-table__checkbox atomic-table__cell--center"
>
<input
v-model="isChecked"
type="checkbox"
:indeterminate="isIndeterminate"
>
</th>
<!-- 其他欄位 -->
</thead>
<tr
v-for="(item, index) in items"
:key="item[itemKey]"
class="atomic-table__row"
>
<td
v-if="isSelectable"
class="atomic-table__cell atomic-table__checkbox atomic-table__cell--center"
>
<input
v-model="selectedWritable"
:value="item"
>
</td>
<!-- 其他欄位 -->
</tr>
這樣一來我們就完成了表格多選的功能了!
為了讓使用輔助技術的使用者能夠更好地了解表格的內容,我們需要加上一些元素與無障礙相關的設定,讓我們的元件更加友善。
表格標題可以幫助使用者更快速地了解表格的內容,我們可以使用 <caption>
來定義表格的標題。我們可以在 <AtomicTable>
上接受一個可選的 caption
來定義表格的標題。
interface AtomicTableProps {
// ...
/**
* 表格標題
*/
caption?: string;
/**
* 表格標題位置
*/
captionSide?: 'top' | 'bottom' | 'hidden';
}
如果 <caption>
存在,它必須被放在 <table>
的第一個子元素(最上方)。
<table class="atomic-table__table">
<caption
v-if="caption"
class="atomic-table__caption"
>
{{ caption }}
</caption>
<!-- ... -->
</table>
我們不一定總是希望 <caption>
出現在表格最上面,或是被顯示出來。這時可以透過 CSS 的 caption-side
來設定 <caption>
的位置。
.atomic-table {
&__caption {
padding: 8px;
&--top {
caption-side: top;
}
&--bottom {
caption-side: bottom;
}
&--hidden {
@include sr-only;
}
}
}
這樣一來,不論是一般使用者或是使用輔助技術瀏覽網頁的使用者都能更清楚地了解表格的內容。
aria-label
在 <AtomicTable>
中,排序用的按鈕我們使用了 SVG Icon,這對於使用輔助技術的使用者來說可能會造成一些困擾,因為他們無法得知目前顯示的 Icon 是什麼意思。這時我們可以透過 aria-label
來定義按鈕的功能。
<button
v-if="sort && column.sortable"
type="button"
@click="onSort(column.key)"
:aria-label="`排序 ${column.label}`"
>
<SortIcon />
</button>
aria-sort
當使用者點選排序按鈕時,我們可以透過 aria-sort
來告訴使用者目前的排序狀態。
<th
v-for="column in columns"
:key="column.key"
class="atomic-table__cell"
:aria-sort="getAriaSort(column)"
>
{{ column.label }}
</th>
const getAriaSort = (column: TableColumn): AriaAttributes['aria-sort'] => {
if (!column.sortable || !props.sort) return undefined;
const { column: name, direction } = props.sort;
if (name !== column.key) return 'none';
return direction === 'asc'
? 'ascending'
: direction === 'desc'
? 'descending'
: 'none';
};
這樣一來,當欄位有排序功能時,使用者就可以更清楚地了解目前的排序狀態。
<tr class="atomic-table__row">
<th aria-sort="ascending">...</th>
<th aria-sort="none">...</th>
<th>...</th>
</tr>
<table>
在網頁中是一個很「聰明」的元素,它可以依照內容自動調整每個欄位最適合的寬度。不過,由於規劃表格時所需要的元素比較多,必要的就有 <thead>
、<tbody>
、<tr>
、<th>
與 <td>
等,模板結構很容易變得複雜且難以維護。
加入 <AtomicTable>
之後就會變得容易很多,我們只要定義好每個欄位與欄位資料,就可以快速地將想要的表格呈現出來。樣式的部分可以透過像是 bodyRowClass
、bodyCellClass
、column.bodyCellClass
等等方式來客製化每個 Row 甚至每個 Cell 的 Class。如果想要自定義每個欄位的內容,可以使用 column.render
或是動態 slot 的方式針對每個欄位做更進階的處理。
另外,<AtomicTable>
也提供了排序功能,讓使用者可以依照自己的需求來排序資料。不過,由於不希望將排序的邏輯耦合在元件中,我們只處理排序資料的變更,而不主動幫使用者排序。
最後,我們幫 <AtomicTable>
加入了多行選取的功能與無障礙相關的設定,讓使用者可以更方便地使用表格,並且讓表格對於使用輔助技術的網頁使用者更友善。
<AtomicTable>
原始碼:AtomicTable.vue