Table要素を使ったtreegridの実装
TreeViewを実現する際、Table要素では行の入れ子構造ができない。そのため、WAI-ARIAとJavaScriptを使って実装する。
なお、Table構造にする必要がない場合は、HTMLのみでTreeView UIをつくる のようにdetails要素を使うのが簡単だ。
前提知識
テーブル構造を持ちながら、各行に階層構造を持つUIはrole=“treegrid”を使う。WindowsにおけるExplorer、macOSにおけるFinderのリストビューがこれに該当する。
treegridを使う場合は、以下のロールや属性を併用する。
- role=“gridcell”
role="grid"
またはrole="treegrid"
の中で、ひとつのデータを表す- table要素に
role="grid"
またはrole="treegrid"
が指定されている場合は、td要素がrole="gridcell"
として認識される
- aria-level=“n”
- 階層化された構造と自身の階層を示すために使用する
- aria-expanded=“boolean”
- 紐づく要素が展開されているかどうかを示す
- aria-owns=“idrefs”
- DOM上の親子関係とは異なるアクセシビリティツリー上の所有関係(親子関係)を明示的に示す
実装例
HTML
<table role="treegrid">
<thead>
<tr>
<th>名前</th>
<th>サイズ</th>
</tr>
</thead>
<tbody>
<tr aria-level="1" aria-expanded="true" aria-owns="file01 file02" style="--level: 1;">
<td>フォルダA</td>
<td>-</td>
</tr>
<tr aria-level="2" id="file01" style="--level: 2;">
<td>ファイル01.txt</td>
<td>14KB</td>
</tr>
<tr aria-level="2" id="file02" style="--level: 2;">
<td>ファイル02.txt</td>
<td>1MB</td>
</tr>
<tr aria-level="2" aria-expanded="true" aria-owns="file04 file05" style="--level: 2;">
<td>フォルダAA</td>
<td>-</td>
</tr>
<tr aria-level="3" id="file04" style="--level: 3;">
<td>ファイル04.txt</td>
<td>2MB</td>
</tr>
<tr aria-level="1" aria-expanded="false" aria-owns="file03" style="--level: 1;">
<td>フォルダB</td>
<td>-</td>
</tr>
<tr aria-level="2" id="file03" hidden style="--level: 2;">
<td>ファイル03.txt</td>
<td>1GB</td>
</tr>
</tbody>
</table>
CSS
@property --level {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
tr > td:first-child {
/* attr(aria-*)をしたいがデータが取得できない */
/* padding-left: attr(aria-level type<number>) * 1em; */
padding-left: calc(var(--level) * 1em);
}
JavaScript(JSX)
const tree = [
{
id: 'folderA', name: 'フォルダA', size: '-', children: [
{ id: 'file01', name: 'ファイル01.txt', size: '14KB' },
{ id: 'file02', name: 'ファイル02.txt', size: '1MB' },
{
id: 'folderAA', name: 'フォルダAA', size: '-', children: [
{ id: 'file04', name: 'ファイル04.txt', size: '2MB' },
{ id: 'file05', name: 'ファイル05.txt', size: '3MB' },
],
},
],
},
{
id: 'folderB', name: 'フォルダB', size: '-', children: [
{ id: 'file03', name: 'ファイル03.txt', size: '1GB' }
],
},
]
function TreeGrid({ tree }) {
const isFolder = item => item.size === '-'
const [expanded, setExpanded] = useState<Record<string, boolean>>(
tree.flat(Infinity).reduce((acc, item) => {
if (isFolder(item)) {
acc[item.id] = false
}
return acc
}, {})
)
const toggleExpand = item => {
setExpanded(prev => ({ ...prev, [item.id]: !prev[item.id] }))
item.children.filter(child => isFolder(child)).forEach(child => {
setExpanded(prev => ({ ...prev, [child.id]: false }))
toggleExpand(child)
})
}
const renderRow = (item, level = 1, isHidden = false) => {
const isExpanded = expanded[item.id] || false
return (
<tr
aria-level={level}
aria-expanded={isFolder(item) && isExpanded}
aria-owns={isFolder(item) && item.children?.map(child => child.id).join(' ')}
hidden={isHidden}
style={{ '--level': level }}
>
<td
onClick={() => isFolder(item) && toggleExpand(item)}
>{item.name}</td>
<td>{item.size}</td>
</tr>
{item.children?.map(child => renderRow(child, level + 1, !isExpanded))}
)
}
return (
<table role="treegrid">
<thead>
<tr>
<th>名前</th>
<th>サイズ</th>
</tr>
</thead>
<tbody>
{tree.map(item => renderRow(item))}
</tbody>
</table>
)
}
デモ
名前 | サイズ |
---|---|
フォルダA | - |
ファイル01.txt | 14KB |
ファイル02.txt | 1MB |
フォルダAA | - |
ファイル04.txt | 2MB |
フォルダB | - |
ファイル03.txt | 1GB |