AstroのBlogにモバイル表示用のタブバー(TabBar)を追加する
初稿:
- 6 min read -

記事概要
- 先日のBloggerからAstroへ移行した記事の別途詳細
 
※参考 - Blog移行記事
10年以上の期間お世話になったGoogle Bloggerに別れを告げ、この度AstroでBlogサイトを構築し移行した。Astroは静的サイトを手軽に開発できる軽量フレームワーク。無料のテンプレートをベースにカスタマイズを行った。それなりの作業ボリュームとなったので、詳細は別記事に分け、今回は移行作業全体をまとめる。
目的
- AstroのBlogサイトにモバイル表示用のタブバー(以降、TabBar)を追加する
 - このBlogのモバイル表示時に使用しているものと同じ
 - 今回、次の3つを配置する
- ホームへ戻る
 - Topへ戻る
 - 目次を表示する
 
 - 一連の実装手順についてまとめる
 
用語説明
Astro とは?
Astroは、ブログやマーケティング、eコマースなど、コンテンツ駆動のウェブサイトを作成するためのウェブフレームワークです。Astroは、新しいフロントエンドアーキテクチャを開拓し、他のフレームワークと比較してJavaScriptのオーバーヘッドと複雑さを低減することで知られています。高速でSEOに優れたウェブサイトが必要なら、Astroが最適です。 — Astro公式Docs より引用をDeepLで翻訳
作業環境
- OS - Ubuntu-22.04LTS on WSL2
 - Node.js - v20.14.0
 - pnpm - v9.4.0
 - Astro - v4.11.3
 
作業概要
- TabBar componentを実装する
 - layout componentに、TabBar componentを組み込む
 
作業詳細
TabBar componentを実装する
仕様
- ファイル名はMobileFooter.astro
 - componentに以下を実装する
- 目次用dialogのレンダリング
 - TabBarのレンダリング
 - ボタン操作追加javascript
 
 
MobileFooter component
- propsで受け取ったheadingsで目次を作成しdialogでレンダリングする
 - dialogの次、fixed-footer-menuがTabBar
 - fixed-footer-menuはmd以上で表示されるようcssで制御
 - script内はボタン操作を実装
- ホームへ戻るはアンカータグ
 - Topへ戻る、目次dialog表示はclickイベント
 
 - cssはTailwind CSS、iconはTabler Iconsを使用
 
---
import type { MarkdownHeading } from 'astro'
import TableOfContents from '@/components/ui/TableOfContents'
import HomeIcon from '@/components/icons/HomeIcon'
import CloseIcon from '@/components/icons/CloseIcon'
import ToTopIcon from '@/components/icons/ToTopIcon'
import TableOfContentsIcon from '@/components/icons/TableOfContentsIcon'
type Props = {
  headings: MarkdownHeading[]
}
const { headings } = Astro.props
---
<dialog
  id='toc-dialog'
  class='h-full w-full bg-white text-black backdrop:bg-gray-500 backdrop:opacity-80 dark:bg-gray-900 dark:text-white dark:backdrop:bg-gray-700'
>
  <div class='h-full w-full flex-wrap items-center justify-center p-4'>
    <p class='mb-4 mt-2 border-b border-b-gray-300 pb-2 text-xl font-semibold'>目次</p>
    {
      headings && headings.length > 0 ? (
        <TableOfContents {headings} />
      ) : (
        <p class='text-lg'>この記事に目次はありません</p>
      )
    }
  </div>
  <form>
    <button
      formmethod='dialog'
      class='absolute right-4 top-4 z-40 h-8 w-8 rounded-md border border-gray-400 bg-white p-1 dark:bg-inherit'
    >
      <CloseIcon />
    </button>
  </form>
</dialog>
<div
  id='fixed-footer-menu'
  class='fixed bottom-0 z-50 block w-full bg-tertiary pb-2 pt-1 text-white dark:bg-slate-900 md:hidden'
>
  <ul class='m-0 flex w-full list-none p-0'>
    <li class='m-0 w-1/3 items-center justify-center p-0'>
      <a
        href='/'
        class='grid w-full place-items-center gap-1 pt-3 text-xxs no-underline sm:text-xs'
      >
        <HomeIcon />
        <p class='m-0 p-0 text-xxs sm:text-xs'>ホーム</p>
      </a>
    </li>
    <li class='m-0 w-1/3 items-center justify-center p-0'>
      <button
        id='toTop-button'
        class='grid w-full place-items-center gap-1 pt-3 text-xxs no-underline sm:text-xs'
      >
        <ToTopIcon />
        <p class='m-0 p-0 text-xxs sm:text-xs'>トップ</p>
      </button>
    </li>
    <li class='m-0 w-1/3 items-center justify-center p-0'>
      <button
        id='show-toc-button'
        class='grid w-full place-items-center gap-1 pt-3 text-xxs no-underline sm:text-xs'
      >
        <TableOfContentsIcon />
        <p class='m-0 p-0 text-xxs sm:text-xs'>目 次</p>
      </button>
    </li>
  </ul>
</div>
<script>
  const tocDialog = () => {
    const dialog = document.getElementById('toc-dialog') as HTMLDialogElement
    const openBtn = document.getElementById('show-toc-button') as HTMLButtonElement
    const toTopBtn = document.getElementById('toTop-button') as HTMLButtonElement
    openBtn &&
      openBtn.addEventListener('click', () => {
        dialog.showModal()
      })
    dialog &&
      dialog.addEventListener('click', () => {
        dialog.close()
      })
    toTopBtn &&
      toTopBtn.addEventListener('click', function () {
        window.scrollTo({
          top: 0,
          behavior: 'smooth' // スムーズスクロール
        })
      })
  }
  tocDialog()
  document.addEventListener('astro:after-swap', tocDialog)
</script>- dialog内で使用している目次componentは以下2つ
 - h2とh3のみをターゲットに目次を作成している
 
TableOfContents.astro
---
import TabletOfContentsHeading from './TabletOfContentsHeading.astro'
const { headings } = Astro.props
type TableOfContent = {
  depth: number
  text: string
  slug: string
  subheadings: TableOfContent[]
}
const targetHeadings: TableOfContent[] = headings.filter(
  (e: TableOfContent) => e.depth === 2 || e.depth === 3
)
const toc = buildToc(targetHeadings)
function buildToc(headings: TableOfContent[]) {
  let toc: TableOfContent[] = []
  let parentHeadings = new Map()
  headings.forEach((h) => {
    let heading = { ...h, subheadings: [] }
    parentHeadings.set(heading.depth, heading)
    // Change 2 to 1 if your markdown includes your <h1>
    if (heading.depth === 1 || heading.depth === 2) {
      toc.push(heading)
    } else {
      parentHeadings.get(heading.depth - 1).subheadings.push(heading)
    }
  })
  return toc
}
---
<ul class='flex flex-col gap-1 [text-wrap:pretty]'>
  {toc.map((heading) => <TabletOfContentsHeading heading={heading} />)}
</ul>TabletOfContentsHeading.astro
---
import { cn } from '@/utils'
const { heading } = Astro.props
type Heading = {
depth: number
text: string
slug: string
subheadings: Heading[]
}
export interface Props {
	heading: Heading
}
---
<li class='flex flex-col'>
	<a
		href={'#' + heading.slug}
		class={cn(
			`bg-slate-200 dark:bg-slate-800 dark:hover:bg-primary-500 hover:bg-primary-400 hover:text-white  py-1 px-3 dark:text-white mb-2 first-letter:uppercase w-fit line-clamp-1 overflow-hidden text-base rounded-lg`
		)}
	>
		{heading.text}
	</a>
	{
		heading.subheadings.length > 0 && (
			<ul class='ml-3'>
				{heading.subheadings.map((subheading) => (
					<Astro.self heading={subheading} />
				))}
			</ul>
		)
	}
</li>layout componentに、TabBar componentを組み込む
- Blog記事を表示するcomponent BlogPostに先ほど作成したMobileFooterをimport
 - タグを追加しpropsにheadingsを渡す
 
---
import type { CollectionEntry } from 'astro:content'
import BaseLayout from '@/layouts/BaseLayout'
import Tag from '@/components/ui/Tag'
import type { MarkdownHeading } from 'astro'
import MobileFooter from '@/components/widgets/MobileFooter'
type Props = {
  id: CollectionEntry<'blog'>['id']
  data: CollectionEntry<'blog'>['data']
  headings: MarkdownHeading[]
  readTime: string
}
const { data, readTime, headings, id } = Astro.props
const { title, description, pubDate, modDate, heroImage = null, tags } = data
---
<BaseLayout title={title} description={description} articleDate={pubDate}>
  <article class='min-w-full sm:max-w-none md:max-w-none md:py-4'>
    <header class='mb-3 flex flex-col items-center justify-center gap-6'>
      <div class='flex flex-col gap-2'>
        <h1 class='text-balance text-center text-3xl font-semibold md:pb-2.5 md:text-5xl'>
          {title}
        </h1>
      </div>
    </header>
    <div>
      <slot />
    </div>
  </article>
  <MobileFooter slot='mobileFooter' headings={headings} />
</BaseLayout>以上