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>
以上