neputa note

AstroのBlogにモバイル表示用のタブバー(TabBar)を追加する

初稿:

- 6 min read -

img of AstroのBlogにモバイル表示用のタブバー(TabBar)を追加する

記事概要

  • 先日のBloggerからAstroへ移行した記事の別途詳細

※参考 - Blog移行記事

BlogをBloggerからAstroへ移行した

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を使用
mobileFooter.astro
---
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
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
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を渡す
BlogPost.astro
---
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>

以上

目次