≪ 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