≪ Today I learned. RSS購読
公開日
タグ
HTML , CSS , JavaScript , Accessibility
著者
ダーシノ

Table要素を使ったtreegridの実装

TreeViewを実現する際、Table要素では行の入れ子構造ができない。そのため、WAI-ARIAとJavaScriptを使って実装する。

なお、Table構造にする必要がない場合は、HTMLのみでTreeView UIをつくる のようにdetails要素を使うのが簡単だ。

前提知識

テーブル構造を持ちながら、各行に階層構造を持つUIはrole=“treegrid”を使う。WindowsにおけるExplorer、macOSにおけるFinderのリストビューがこれに該当する。

treegridを使う場合は、以下のロールや属性を併用する。

実装例

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.txt14KB
ファイル02.txt1MB
フォルダAA-
ファイル04.txt2MB