astro-notion-blogの(トグル)見出しをカスタマイズする

Featured image of the post

目次

まえがき

📄Arrow icon of a page linkマウスホバーでアンカーリンクが浮き出る見出しを作る にて、見出しへのリンクをアンカーリンクのアイコンに埋め込み、マウスホバー時に出現させるCSSを考えました。

これ以来ずっとこのブログに取り込みたいと考えていたのですが、見出しのスタイルはトグル見出しとの兼ね合いがあるため全体的にUIやデザインを考えねばならず、非常に難航していました。

今回ようやく表に出せるものが完成したため、この記事で解説します。

ソースコード

嵩張るので畳んでおきます
---
import * as interfaces from '../../lib/interfaces.ts'
import { buildHeadingId } from '../../lib/blog-helpers.ts'
import RichText from './RichText.astro'
import NotionBlocks from '../NotionBlocks.astro'
import { Icon } from 'astro-icon'

export interface Props {
  block: interfaces.Block
  headings: interfaces.Block[]
}

const { block, headings } = Astro.props

const id = buildHeadingId(block.Heading1)
---

{
  block.Heading1.IsToggleable ? (
    <details class="Heading1-toggle">
      <summary class="heading-wrapper">
        <h3 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading1.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h3>
        <div class="icon-wrapper" />
      </summary>
      <div class="toggle-contents">
        {block.Heading1.Children && (
          <NotionBlocks blocks={block.Heading1.Children} headings={headings} />
        )}
      </div>
    </details>
  ) : (
    <div class="Heading1">
      <div class="heading-wrapper">
        <h3 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading1.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h3>
      </div>
    </div>
  )
}

<style>
  .Heading1-toggle,
  .Heading1 {
    font-size: 1.8rem;
    margin-block-start: 2rem;
    margin-block-end: 0;
    margin-inline: 0;
    --border-width: 6px;
    --border-style: double;
    --border-color: #8a8a8a8a;
  }
  .heading-wrapper {
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    margin: 0;
    padding: 0;
  }
  .Heading1-toggle > .heading-wrapper {
    display: flex;
    justify-content: space-between;
    align-items: stretch;
    cursor: pointer;
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    &::-webkit-details-marker {
      display: none;
    }
  }
  .Heading1-toggle > .heading-wrapper > .icon-wrapper {
    flex-shrink: 0;
    width: 2rem;
    position: relative;
  }
  .Heading1-toggle > .heading-wrapper > .icon-wrapper::after {
    position: absolute;
    content: '';
    top: 50%;
    left: calc(25% + var(--border-width));
    translate: -50% -75%;
    width: 1rem;
    height: 1rem;
    border-width: 2px;
    border-color: var(--fg);
    border-style: solid solid none none;
    display: inline-block;
    transform: rotate(135deg);
  }
  .Heading1-toggle[open] > .heading-wrapper > .icon-wrapper::after {
    translate: -50% -25%;
    transform: rotate(315deg);
  }
  @media (hover: hover) {
    .Heading1-toggle > .heading-wrapper:not(:hover) > .icon-wrapper::after {
      border-color: var(--border-color);
    }
    .Heading1-toggle > .heading-wrapper:hover > .icon-wrapper::after {
      scale: 1.2;
    }
  }
  .Heading1-toggle[open] {
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    > .heading-wrapper {
      border-inline-end: none;
    }
    > .toggle-contents {
      margin-block-end: 1rem;
    }
  }

  h3 {
    font-size: inherit;
    color: var(--fg);
    margin: 0;
    padding-block: 0.4em;
    padding-inline: 0;
  }
  h3 > a {
    position: relative;
  }
  h3 > a > [astro-icon] {
    position: absolute;
    translate: -100%;
    margin-block-start: 0.2em;
    height: 1em;
    color: var(--fg);
    opacity: 0;
  }
  @media (hover: hover) {
    .heading-wrapper:hover > h3 > a > [astro-icon] {
      opacity: 0.5;
    }
    .heading-wrapper > h3 > a > [astro-icon]:hover {
      opacity: 1;
      scale: 1.2;
    }
  }
</style>

<style is:global>
  [class^='Heading1'] + [class^='Heading1'] {
    margin-block-start: 0 !important;
  }
</style>
src\components\notion-blocks\Heading1.astro
---
import * as interfaces from '../../lib/interfaces.ts'
import { buildHeadingId } from '../../lib/blog-helpers.ts'
import RichText from './RichText.astro'
import NotionBlocks from '../NotionBlocks.astro'
import { Icon } from 'astro-icon'

export interface Props {
  block: interfaces.Block
  headings: interfaces.Block[]
}

const { block, headings } = Astro.props

const id = buildHeadingId(block.Heading2)
---

{
  block.Heading2.IsToggleable ? (
    <details class="Heading2-toggle">
      <summary class="heading-wrapper">
        <h4 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading2.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h4>
        <div class="icon-wrapper" />
      </summary>
      <div class="toggle-contents">
        {block.Heading2.Children && (
          <NotionBlocks blocks={block.Heading2.Children} headings={headings} />
        )}
      </div>
    </details>
  ) : (
    <div class="Heading2">
      <div class="heading-wrapper">
        <h4 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading2.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h4>
      </div>
    </div>
  )
}

<style>
  .Heading2-toggle,
  .Heading2 {
    font-size: 1.5rem;
    margin-block-start: 2rem;
    margin-block-end: 0;
    margin-inline: 0;
    --border-width: 2px;
    --border-style: solid;
    --border-color: #8a8a8a8a;
  }
  .heading-wrapper {
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    margin: 0;
    padding: 0;
  }
  .Heading2-toggle > .heading-wrapper {
    display: flex;
    justify-content: space-between;
    align-items: stretch;
    cursor: pointer;
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    &::-webkit-details-marker {
      display: none;
    }
  }
  .Heading2-toggle > .heading-wrapper > .icon-wrapper {
    flex-shrink: 0;
    width: 2rem;
    position: relative;
  }
  .Heading2-toggle > .heading-wrapper > .icon-wrapper::after {
    position: absolute;
    content: '';
    top: 50%;
    left: calc(25% + var(--border-width));
    translate: -50% -75%;
    width: 1rem;
    height: 1rem;
    border-width: 2px;
    border-color: var(--fg);
    border-style: solid solid none none;
    display: inline-block;
    transform: rotate(135deg);
  }
  .Heading2-toggle[open] > .heading-wrapper > .icon-wrapper::after {
    translate: -50% -25%;
    transform: rotate(315deg);
  }
  @media (hover: hover) {
    .Heading2-toggle > .heading-wrapper:not(:hover) > .icon-wrapper::after {
      border-color: var(--border-color);
    }
    .Heading2-toggle > .heading-wrapper:hover > .icon-wrapper::after {
      scale: 1.2;
    }
  }
  .Heading2-toggle[open] {
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    > .heading-wrapper {
      border-inline-end: none;
    }
    > .toggle-contents {
      margin-block-end: 1rem;
    }
  }

  h4 {
    font-size: inherit;
    color: var(--fg);
    margin: 0;
    padding-block: 0.4em;
    padding-inline: 0;
  }
  h4 > a {
    position: relative;
  }
  h4 > a > [astro-icon] {
    position: absolute;
    translate: -100%;
    margin-block-start: 0.2em;
    height: 1em;
    color: var(--fg);
    opacity: 0;
  }
  @media (hover: hover) {
    .heading-wrapper:hover > h4 > a > [astro-icon] {
      opacity: 0.5;
    }
    .heading-wrapper > h4 > a > [astro-icon]:hover {
      opacity: 1;
      scale: 1.2;
    }
  }
</style>

<style is:global>
  [class^='Heading1'] + [class^='Heading2'],
  [class^='Heading2'] + [class^='Heading2'] {
    margin-block-start: 0 !important;
  }
</style>
src\components\notion-blocks\Heading2.astro
---
import * as interfaces from '../../lib/interfaces.ts'
import { buildHeadingId } from '../../lib/blog-helpers.ts'
import RichText from './RichText.astro'
import NotionBlocks from '../NotionBlocks.astro'
import { Icon } from 'astro-icon'

export interface Props {
  block: interfaces.Block
  headings: interfaces.Block[]
}

const { block, headings } = Astro.props

const id = buildHeadingId(block.Heading3)
---

{
  block.Heading3.IsToggleable ? (
    <details class="Heading3-toggle">
      <summary class="heading-wrapper">
        <h5 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading3.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h5>
        <div class="icon-wrapper" />
      </summary>
      <div class="toggle-contents">
        {block.Heading3.Children && (
          <NotionBlocks blocks={block.Heading3.Children} headings={headings} />
        )}
      </div>
    </details>
  ) : (
    <div class="Heading3">
      <div class="heading-wrapper">
        <h5 id={id}>
          <a href={`#${id}`}>
            <Icon name="gg:link" />
          </a>
          {block.Heading3.RichTexts.map((richText: interfaces.RichText) => (
            <RichText richText={richText} />
          ))}
        </h5>
      </div>
    </div>
  )
}

<style>
  .Heading3-toggle,
  .Heading3 {
    font-size: 1.25rem;
    margin-block-start: 1.5rem;
    margin-block-end: 0;
    margin-inline: 0;
    --border-width: 1px;
    --border-style: dashed;
    --border-color: #8a8a8a8a;
  }
  .heading-wrapper {
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    margin: 0;
    padding: 0;
  }
  .Heading3-toggle > .heading-wrapper {
    display: flex;
    justify-content: space-between;
    align-items: stretch;
    cursor: pointer;
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    &::-webkit-details-marker {
      display: none;
    }
  }
  .Heading3-toggle > .heading-wrapper > .icon-wrapper {
    flex-shrink: 0;
    width: 2rem;
    position: relative;
  }
  .Heading3-toggle > .heading-wrapper > .icon-wrapper::after {
    position: absolute;
    content: '';
    top: 50%;
    left: calc(25% + var(--border-width));
    translate: -50% -75%;
    width: 1rem;
    height: 1rem;
    border-width: 2px;
    border-color: var(--fg);
    border-style: solid solid none none;
    display: inline-block;
    transform: rotate(135deg);
  }
  .Heading3-toggle[open] > .heading-wrapper > .icon-wrapper::after {
    translate: -50% -25%;
    transform: rotate(315deg);
  }
  @media (hover: hover) {
    .Heading3-toggle > .heading-wrapper:not(:hover) > .icon-wrapper::after {
      border-color: var(--border-color);
    }
    .Heading3-toggle > .heading-wrapper:hover > .icon-wrapper::after {
      scale: 1.2;
    }
  }
  .Heading3-toggle[open] {
    border-inline-end: var(--border-width) var(--border-style)
      var(--border-color);
    border-block-end: var(--border-width) var(--border-style)
      var(--border-color);
    > .heading-wrapper {
      border-inline-end: none;
    }
    > .toggle-contents {
      margin-block-end: 1rem;
    }
  }

  h5 {
    font-size: inherit;
    color: var(--fg);
    margin: 0;
    padding-block: 0.4em;
    padding-inline: 0;
  }
  h5 > a {
    position: relative;
  }
  h5 > a > [astro-icon] {
    position: absolute;
    translate: -100%;
    margin-block-start: 0.2em;
    height: 1em;
    color: var(--fg);
    opacity: 0;
  }
  @media (hover: hover) {
    .heading-wrapper:hover > h5 > a > [astro-icon] {
      opacity: 0.5;
    }
    .heading-wrapper > h5 > a > [astro-icon]:hover {
      opacity: 1;
      scale: 1.2;
    }
  }
</style>

<style is:global>
  [class^='Heading1'] + [class^='Heading3'],
  [class^='Heading2'] + [class^='Heading3'],
  [class^='Heading3'] + [class^='Heading3'] {
    margin-block-start: 0 !important;
  }
</style>
src\components\notion-blocks\Heading3.astro

おまけのトグル

---
import * as interfaces from '../../lib/interfaces.ts'
import { snakeToKebab } from '../../lib/style-helpers.ts'
import RichText from './RichText.astro'
import NotionBlocks from '../NotionBlocks.astro'
import '../../styles/notion-color.css'

export interface Props {
  block: interfaces.Block
  headings: interfaces.Block[]
}

const { block, headings } = Astro.props
---

<details class={`toggle ${snakeToKebab(block.Toggle.Color)}`}>
  <summary>
    {
      block.Toggle.RichTexts.map((richText: interfaces.RichText) => (
        <RichText richText={richText} />
      ))
    }
  </summary>
  <div>
    <NotionBlocks blocks={block.Toggle.Children} headings={headings} />
  </div>
</details>

<style>
  .toggle {
    margin-block-start: 1.5rem;
    padding: 0.4rem;
  }

  .toggle > summary {
    cursor: pointer;
  }

  .toggle > summary > a {
    display: inline;
  }

  .toggle > div {
  }

  .toggle[open] {
    border-inline-end: 1px solid #8a8a8a8a;
  }
</style>
src\components\notion-blocks\Toggle.astro

仕様

マウスホバーでアンカーリンクが浮き出る

Image in a image block

📄Arrow icon of a page linkマウスホバーでアンカーリンクが浮き出る見出しを作る

大体この記事と同じように作りました。

違うのは、astro-Iconを使ってSVG画像を表示している点と、アイコン自体をホバーすると少しだけ大きくなる点くらいでしょうか。

<a href={`#${id}`} id={id}>
  <h3>
    {block.Heading1.RichTexts.map((richText: interfaces.RichText) => (
      <RichText richText={richText} />
    ))}
  </h3>
</a>
元の見出しのHTML

カスタム前は上のように見出しのテキスト全体がa要素になっていたのですが、カスタム後はh要素の中にa要素を格納し、a要素はアイコンだけになりました。

<h3 id={id}>
  <a href={`#${id}`}>
    <Icon name="gg:link" />
  </a>
  {block.Heading1.RichTexts.map((richText: interfaces.RichText) => (
    <RichText richText={richText} />
  ))}
</h3>
カスタム後の見出しのHTML

これにより、見出しはただのテキストとしての挙動になります。

アコーディオンメニューっぽく見えるトグル見出し

トグル見出しのデザインは本当に困りました。

殆ど僕のこだわりなんですが理由を列挙すると、

  • トグル見出しという概念が世間一般的ではないため参考にできるものがほとんどない
  • 見出しとトグル見出しは、折りたたみができる点以外は全く同一の機能を持つべきオブジェクトであり、デザインに乖離があると気持ちが悪い
  • アンカーリンクは見出しの左側に出現させるため、見出しとトグル見出しを見分けるためのデザイン上の要素は右側に配置しなければならない
  • しかし人間の視線は左からZの字のように動くため、右側の要素は見落とされやすい
  • トグル見出しであることを認識させるためのデザイン要素が右側にあるため、見落とされないために視線を右へと誘導する下線を右端まで伸ばす必要がある
  • 下線による見出しの装飾では見出し3で下線を右端まで伸ばさずに途中で止めるようなデザインパターンが多いが、上の理由によってそれができず、端から端まで下線があるデザインに制限される

最終的に、右にアローアイコンで状態を表示したアコーディオンメニューのような見た目にしました。

Image in a image block

アローアイコンはホバー時に色が変わって少し大きくなりますが、スマートフォンではマウスホバーが無いため、色は最初から変えて目立たせておき、アイコンサイズの変化は発生しないような分岐になっています。

通常の見出しとのデザイン上の違いは、右端のアイコンと右側のボーダー(開いた時には隠れていたコンテンツの右側に伸びる)です。

Image in a image block

右のボーダーで分かりづらいのですが、アローアイコンは見出しレベルに関わらず左右の位置が揃うようにしてあります。小さなこだわりです。

改めて見比べてみても、どちらも同格の見出しレベルを表しつつ開閉ができるかどうかは右端を見れば判別ができるというなかなか良い感じのデザインができたと思います。

(自己)満足度がかなり高いです。

連続した見出しはレベルと順番に応じて間隔を切り替える

これはStackレイアウトのフクロウセレクタを知ったときに思いついたんですが、隣接セレクタを使用することで、見出しが連続した際のマージンを設定できます。

<style is:global>
  [class^='Heading1'] + [class^='Heading3'],
  [class^='Heading2'] + [class^='Heading3'],
  [class^='Heading3'] + [class^='Heading3'] {
    margin-block-start: 0 !important;
  }
</style>
src\components\notion-blocks\Heading3.astroの末尾部分

アコーディオンメニュー同士に間隔が空いているとかなり気持ち悪いため、上にある見出しレベル以下の見出しレベルの見出しが連続した際はmargin-block-start: 0 !important;にしています。

※ちなみに僕はこのブログのmargin指定は全てmargin-block-startのみの一方向に統一しました。下記記事の影響と、margin上下どっち派論争を色々読んでの結論です。

このように、見出しが順方向に小さくなる(か同じレベルが連続する)と、見出し同士が連結します。

Image in a image block

トグル見出しはよくあるアコーディオンメニューっぽくなります。

通常の見出しでも、例えば見出し1と見出し2が連続する際に、間に文がなければ隙間を詰めておいて欲しいと思うのでこの仕様は思いついて良かったと思います。

見出しレベルが小→大になるか、見出し間になにか挟まる場所には通常仕様で隙間ができます。

Image in a image block

あとがき

📄Arrow icon of a page linkastro-notion-blogの目次を折りたためるようにする の記事での目次に続き、トグル見出しもいろいろ弄ったことで、detailssummaryへの理解が深まりました。

次は畳めるコードブロックに活かしたいと思います。