SmartHR Tech Blog

SmartHR 開発者ブログ

弊社のheading levelがめちゃくちゃだった件と 解決のためにしたことの話

こんにちは。プロダクトエンジニアの@atsushimです。

smarthrでは社内でひろくsmarthr-ui が利用されています。
大変便利なのですが、使っていく上で色々問題も発生し、その都度対応してきています。
今回は発生した問題の一つ、Headingのレベルがめちゃくちゃになってしまった、という問題とその解決のためにしたことを紹介しようと思います。

Headingのレベルってそもそも何?なんで重要なの?

HTMLの見出し(Heading)要素は <h1> から <h6> まで、6種類存在します。
h1がレベルとして最上位、h6が最下位になり、ページ内のコンテンツの構造に合わせて、h1から順に指定していきます。

基本的に見出しはレベルが飛ばされることなく、階段状になっていることが良いとされています。
例えば以下のようなマークアップの場合、スクリーンリーダーユーザーなどは 読み飛ばしてしまった内容があるかも? などと不安になってしまう場合があります。

<h1>smarthr-uiの歴史的経緯</h1>
<section>
  <!-- h2が飛ばされており、読み飛ばしたか?!と思われる可能性がある -->
  <h3>smarthr-uiのバグの歴史</h3>
  ...
</section>

このような問題を防ぐため、適切なレベル設定をおこなうことは重要です。

SmartHRのHeadingレベルがめちゃくちゃになっていた理由

smarthr-ui は Heading というコンポーネントを提供しています。
このコンポーネントは前述の h1 から h6 までをまとめたものになっており、過去はtagという属性でレベルを指定し、tag属性を指定しない場合、デフォルトで h1になる仕様でした。
この デフォルトでh1になる という仕様が問題となっていました。

HTMLを直接記述する場合、 h1 h2 など、レベルが分かる形で記述しますが、smarthr-ui/Heading の場合、名前からレベルを意識することがなくなりました。
その結果、tag属性の指定忘れが多発し、社内のプロダクトの至る所で見出しがh1になる、という自体が多発しました。

<h1>見出しレベル1であることが明確</h1>
<h2>見出しレベル2であることも明白</h2>
<Heading>見出しレベル1であることが分かりづらい</Heading>

これらの問題を認識し、tag属性の指定を行っているプロダクトもありましたが、それらのプロダクトでは別の問題が発生しました。
コンポーネントとして切り出したものにHeadingが含まれている場合、レベルを動的に変更したい場合が多発したのです。

const Item= ({ name, description }) => (
  <section>
    {/* h2固定されている */}
    <h2>{name}</h2>
    <p>{description}</p>
  </section>
)  

// 正しいレベルになる例
const MSList = () => (
  <>
    <h1>ジオンMSリスト</h1>
    <Item name=”ザクⅠ” description=”ザクI.../> // h2
  </>
)

// 正しくないレベルになる例
const MSList = () => (
  <section>
    <h2>ジオンMSリスト</h2>
    <Item name=”ザクⅠ” description=”ザクI.../> // h3にしたくない?
  </section>
)

上記例の場合、外部からレベルを注入する必要があります。

const Item= ({ name, description, tag }) => (
  <section>
    <Heading tag={tag || ‘h2’}>{name}</Heading>
    <p>{description}</p>
  </section>
)  

const MSList = () => (
  <section>
    <h2>ジオンMSリスト</h2>
    <Item name=”ザクⅠ” description=”ザクI...tag="h3" />
  </section>
)

これで解決はできます。
出来ますが... 一言でいうと 辛い ですね。
すべてのheadingのレベルを適切に設定することは膨大な労力がかかりますし、はっきり言ってしまえば完璧に設定することは不可能に近いと考えています。

唐突にアウトラインの話

ここで唐突ですが、見出し要素のアウトラインの話をしたいと思います。

見出し要素はそれの範囲、アウトラインという概念が存在します。
アウトラインは、ドキュメント内のすべての見出しをツリー順に並べたものです。
このアウトラインの算出は機械的な判定になるため、文脈などは考慮されません。
そのため人間がSection要素などで指定するほうが(あくまで個人の感想ですが)適切に設定できます。

<!-- 見出しの範囲を明確に指定しない例 -->
<h1>ジオンのMS一覧</h1>
<p>ジオンのMSを称えるdescription</p>
<h2>ザクⅠ</h2>
<p>解説文</p>
<h2>グフ</h2>
<p>解説文</p>
<!-- グフに対する説明ではなく、このページ全体の補足説明 -->
<h3>ジオンMSの歴史</h3>
<p>解説文</p>

<!-- 上記のアウトラインの概念 -->
<!-- outline: start -->
  <h1>ジオンのMS一覧</h1>
  <p>ジオンのMSを称えるdescription</p>
  <!-- outline: start -->
    <h2>ザクⅠ</h2>
    <p>解説文</p>
  <!-- outline: end -->
  <!-- outline: start -->
    <h2>グフ</h2>
    <p>解説文</p>
    <!-- outline: start -->
      <h3>ジオンMSの歴史</h3> <!-- この自動解釈は間違い -->
      <p>解説文</p>
    <!-- outline: end -->
  <!-- outline: end -->
<!-- outline: end -->
<!-- 見出しの範囲を明確に指定した例 -->
<h1>ジオンのMS一覧</h1>
<p>ジオンのMSを称えるdescription</p>
<section>
  <h2>ザクⅠ</h2>
  <p>解説文</p>
</section>
<section>
  <h2>グフ</h2>
  <p>解説文</p>
</section>
<aside>
  <h3>ジオンMSの歴史</h3>
  <p>解説文</p>
</aside>

<!-- 上記のアウトラインの概念 -->
<!-- outline: start -->
  <h1>ジオンのMS一覧</h1>
  <p>ジオンのMSを称えるdescription</p>
  <!-- outline: start -->
    <h2>ザクⅠ</h2>
    <p>解説文</p>
  <!-- outline: end -->
  <!-- outline: start -->
    <h2>グフ</h2>
    <p>解説文</p>
  <!-- outline: end -->
  <!-- outline: start -->
    <h3>ジオンMSの歴史</h3>
    <p>解説文</p>
  <!-- outline: end -->
<!-- outline: end -->

このように、アウトラインの解釈のミスを起こさないため、SectioningContentを使うことを弊社では推奨しています。
そしてある時、ふと思いつきました。

SectioningContent要素で囲まれている数でHeadingレベルの自動計算ができるのでは? と。

SectioningContent要素で囲まれている数でHeadingレベルの自動計算ができるのでは?

// 自動計算の例
<Heading />  // h1
<section>
  <Heading /> // h2
  <aside>
    <Heading /> // h3
  </aside>
<section>

SectioningContentで囲まれていなければh1、それ以降の囲まれている数でレベルをインクリメントしていければ実装できそうです。
実際、この仕様は過去HTMLの仕様として検討されていたこともあるもので、react.dev でも context の活用例として Passing Data Deeply with Context という記事で同じ仕組みが実装されていました。

この例では実際に使用する際、以下の点が問題になりました。

  • section要素のみの対応であり、asideなど他要素に未対応
  • h6を超過するレベルになるとruntime errorになる

特にruntime errorになる点が問題で、発生する可能性が低いとはいえ、せっかくレベルの自動計算が行えるようになっても、意図せず7レベル目になればエラーが発生してしまいます。
smarthr-uiではこれらに対応した実装を行いました。

aside, article, navに対応し、レベル7以上の場合も、aria-level属性を設定することで擬似的に再現しました
section要素がすでに存在し、レベルの自動計算だけしたい場合に利用できるSectioningFragmentコンポーネントも作成しています。

// smarthr-ui/Heading レベル自動計算例
<Section>
  <Heading /> // h2
  <Aside>
    <Heading /> // h3
  </Aside>
  <SectioningFragment>
    <Hoge as=section>
      <Heading />  // h3
    </Hoge>
  </SectioningFragment>
<Section>

このコンポーネントを実装することで、適切なレベル計算が自動で行われるようになり、Headingのtag属性はむしろ利用することでレベルがおかしくなる場合があるため、現在は非推奨になっています。

eslintについて

ここまでで本質的な問題は解決できましたが、そもそもこの問題は実際にsmarthr-ui/Headingが利用されるうえで発覚した問題です。
この問題が発生してもランタイムエラーなどは起きず、修正箇所の判断にはなかなかコストがかかってしまいます。
そのため弊社で既に作成していた eslint-plugin-smarthr で解決することにしました。

実際に作成したルールは a11y-heading-in-sectioning-content で、既に公開しています。

このルールは簡単にいうと HeadingがSection,Aside,Article,Navのいずれかで囲まれていないとエラーになる というものです。
コンポーネント名の末尾、Heading・Sectionなど関連するコンポーネントを判定しチェックを行います。

// smarthr-ui/Heading eslint OK/NG例

<Section><HogeHeading /></Section> // OK
<HogeHeading /> // NG、section系でラップして。

<PageHeading /> // OK、h1なので。
<section>{hoge}</section> // NG、smarthr-ui/Section使え

SmartHRでは歴史的経緯でstyeld-componentsが使われているプロダクトも存在するのですが、その場合、コンポーネント名の命名規則を縛るチェックもあります。

// smarthr-ui/Heading eslint OK/NG例
import { Heading, Section } from ‘smarthr-ui’

const HogeHeading = styled(Heading)`` // OK
const Hoge = styled(HogeHeading)`` // NG、suffixにHeadingをつけろ

const StyledSection = styled.section`` // NG、smarthr-ui/Sectionを使え
const StyledSection = styled(Section)`` // OK

まとめ

マークアップは考えることが多く、適切に書くのはなかなか難しいものです。
また適切なコンポーネントを作ったとしても使われ方によっては問題が発生します。

SmartHRではsmarthr-ui、eslint-plugin-smarthrを組み合わせることでこれらの問題に対応しています。