Chapter 05

コンポーネント作成(Atomic Design)

Thirosue
Thirosue
2022.09.21に更新

サンプルでは、CSSユーティリティのtailwindcssを用いて、画面デザインを実施しています。

tailwindcssでは、汎用クラスの組み合わせで利用するため、一つの要素を作成するために指定するクラスの数が多くなります。

  • ボタンの例
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
  Button
</button>

https://v1.tailwindcss.com/components/buttons

チーム開発の場合、毎回これを指定するのはつらいところがあり、デザイン修正が発生した場合、修正コストが増大し、サイトのデザイン一貫性を保つのが辛くなります。

・参考リンク

https://www.codegrid.net/articles/2017-atomic-design-1/

https://qiita.com/putan/items/ec312314698087fca5b2

上記のため、UIフレームワーク(Material-UI)で定義されているものを参考に、利用箇所が多い、ボタン、リンク、ラベルなどのコンポーネントを作成し、各画面で利用するようにしました。

作成したもの一覧(抜粋)

レベル 名前 補足
Atoms Button
Atoms Link
Atoms Typography
Atoms FormLabel 入力フォームラベル
Molecules Pager 検索一覧のページネーション
Molecules TableHeader 検索テーブルのヘッダ
Organisms Dashboard トップページのダッシュボードエリア
Organisms SearchableTable 再検索可能なテーブル
Templates Confirm 確認ダイアログ - テンプレート
Templates DashboardLayout 管理画面 - レイアウト
Page IndexPage トップページ
Page LoginPage ログインページ
Page PasswordDialog パスワード変更ダイアログ

以下、作成したコンポーネントの一部を紹介していきます。

Buttonコンポーネント(Atoms)

Material-UIなどのUIコンポーネントのAPI仕様を参考にコンポーネントの仕様を決めていきます。ボタンカラー、サイズ、追加class、disabledなどを指定できるようにしています。

components/atoms/button.tsx
import _ from 'lodash'

type Map = {
  key: string
  class: string[]
}

const ColorSetting: Map[] = [
  {
    key: 'primary', // 指定されたカラーに応じて、ユーティリティクラスを追加する
    class: [
      'text-white',
      'bg-indigo-600',
      'hover:bg-indigo-700',
      'focus:ring-indigo-500',
      'border-transparent',
      'primary-button',
    ],
  },
...(中略)...
]

const SizeSetting: Map[] = [
  { key: 'small', class: ['py-2', 'px-4', 'text-sm'] },
]

export const Button = ({
  children,
  color = 'default',
  size = 'small',
  fullWidth = false,
  disabled = false,
  classes = [],
  onClick,
}: {
  children: React.ReactNode
  color?: 'default' | 'primary' | 'secondary' | 'danger'
  size?: 'large' | 'medium' | 'small'
  fullWidth?: boolean
  disabled?: boolean
  classes?: string[]
  onClick?: (event: any) => void
}): JSX.Element => {
  const _color = _.head(
    ColorSetting.filter((map: Map) => map.key === color).map(
      (map: Map) => map.class
    )
  )
  const _size = _.head(
    SizeSetting.filter((map: Map) => map.key === size).map(
      (map: Map) => map.class
    )
  )
  const className = [
    'inline-flex',
    'justify-center',
    'rounded-md',
    'border',
    'focus:outline-none',
    'focus:ring-2',
    'focus:ring-offset-2',
    ..._color, // 指定されたカラーのクラス群をマージ
    ..._size,
    ...classes, // 指定されたカスタムクラスをマージ
  ].join(' ')

  const handleSubmit = (event: any) => {
    if (onClick && !disabled) { // disabledになったら、イベントをキャンセル
      onClick(event)
    }
  }

  return (
    <button
      // fullWithやdisabledの場合、クラスを追加する
      className={`${className} ${fullWidth ? 'w-full' : ''} ${
        disabled ? 'opacity-50 cursor-not-allowed' : ''
      }`}
      onClick={handleSubmit}
    >
      {children}
    </button>
  )
}

export default Button

利用方法

作成したコンポーネントを利用することで、ユーティリティクラスの列挙を防ぎ、サイトのデザイン一貫性を保つのが楽になります。

components/page/login-page.tsx
          <form className="mt-4" onSubmit={handleSubmit(doSubmit)}>
...(中略)...
              <div>
                <Link onClick={passwordModalOpen}>Forgot your password?</Link>
              </div>
            </div>

            <div className="mt-6">
	      <!-- 作成したボタンコンポーネントを利用  -->
              <Button
                color={'primary'}
                fullWidth={true}
                disabled={mutation.isLoading}
              >
                Sign in
              </Button>
            </div>
          </form>

ページネーションコンポーネント(Molecules)

UIフレームワークを利用していると、当たり前に提供されているページネーションコンポーネントも tailwindcssでは自前で作成する必要があります。

デザインは公式のサンプルに存在するので、こちらを利用して、カスタマイズしていきます。

https://tailwindui.com/components/application-ui/navigation/pagination

ページオブジェクト(pageItem)を元にページネーションコンポーネントを作成します。
次ページや指定のページを選択時に再検索をかけるため、検索処理を引数に受け取ります。

components/molecules/pager.tsx
export const Pager = ({
  pageItem,
  search,
}: {
  pageItem: PageItem // 選択ページ数、1ページあたりのアイテム、総ページ数などを設定
  search: (page: number) => Promise<void> // 検索処理
}): JSX.Element => {
  const isFirstActive = pageItem.page !== 1
  const isLastActive = pageItem.page !== pageItem.totalPage

  const [pages, setPages] = useState<number[]>([])
  useEffect(() => {
    setPages([])
    const { page, totalPage } = pageItem
    const from = 1 <= page - PAGER_BUFFER ? page - PAGER_BUFFER : 1
    const to =
      page + PAGER_BUFFER <= totalPage ? page + PAGER_BUFFER : totalPage
    for (let i = from; i <= to; i++) {
      setPages((prev) =>
        [...prev, i].filter((page) => page !== 1 && page !== pageItem.totalPage)
      ) //1ページ目と最終ページは除く
    }
  }, [pageItem.page, pageItem.totalPage]) // 選択ページ数、総ページ数が変化時に、表示するページを切り替える

  return (
    <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">

...(省略)...

        <div>
          <nav
            className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
            aria-label="Pagination"
          >
	    <!-- 「前へ」リンク -->
            <NaviLink
              active={isFirstActive}
              handleClick={() => isFirstActive && search(pageItem.page - 1)}
            >
              <svg
                className="h-5 w-5"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
              >
                <path
                  fillRule="evenodd"
                  d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
                  clipRule="evenodd"
                />
              </svg>
            </NaviLink>
	    <!-- 1ページ目リンク -->
            {1 <= pageItem.totalPage && (
              <PageLink page={1} handleClick={search} active={isFirstActive} />
            )}
	    <!-- 中間ページの表示リンク -->
            {pages.length &&
              pages.map((page, index: number) => (
                <React.Fragment key={index}>
                  {index === 0 && 2 < page && <OmitLink />}
                  <PageLink
                    page={page}
                    handleClick={search}
                    active={pageItem.page !== page}
                  />
                  {index === pages.length - 1 &&
                    page < pageItem.totalPage - 1 && <OmitLink />}
                </React.Fragment>
              ))}
	    <!-- 最終ページリンク -->
            {2 <= pageItem.totalPage && (
              <PageLink
                page={pageItem.totalPage}
                handleClick={search}
                active={isLastActive}
              />
            )}
	    <!-- 「次へ」リンク -->
            <NaviLink
              active={isLastActive}
              handleClick={() => isLastActive && search(pageItem.page + 1)}
            >
              <svg
                className="h-5 w-5"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
              >
                <path
                  fillRule="evenodd"
                  d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
                  clipRule="evenodd"
                />
              </svg>
            </NaviLink>
          </nav>
        </div>
      </div>
    </div>
  )
}

利用方法

各画面でページオブジェクト、検索処理を定義して、コンポーネントに引き渡します。

components/organisms/searchable-table.tsx
    <div className="flex flex-col mt-8">
      <div className="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
        <div className="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
          <table className="min-w-full">
            <thead>
              <TableHeader
                headerItems={headerItems}
                sortItem={sortItem}
                setSortItem={setSortItem}
                search={search}
              />
            </thead>

            {children}
          </table>
	  <!-- 各画面でページオブジェクト、検索処理を定義して、コンポーネントに引き渡します -->
          {queryResult.isFetched && (
            <Pager search={search} pageItem={pageItem} />
          )}
        </div>
      </div>
    </div>

検索可能なテーブルコンポーネント(Organisms)

検索結果一覧を表示するテーブルについて、各画面で一から作成するのは、メンテナンス性が悪いため、コンポーネントを作成していきます。

テーブルヘッダ表示用オブジェクト、ページャ表示用オブジェクト、ソート設定用オブジェクト、検索処理などを引数に受け取りコンポーネントを作成します。

components/organisms/searchable-table.tsx
export const SearchableTable = ({
  children, // テーブルデータは各画面で設定します
  headerItems, // テーブルヘッダ表示設定用オブジェクト
  pageItem, // ページャ表示設定用オブジェクト
  sortItem, // ソート設定用オブジェクト
  setSortItem, // ソートオブジェクト設定用ファンクション
  search, // 検索ファンクション
  queryResult,
}: {
  children: React.ReactNode
  headerItems: TableHeaderItem[]
  pageItem: PageItem
  sortItem: SortItem
  setSortItem: Dispatch<SetStateAction<SortItem>>
  search: (page: number, sortItem?: SortItem) => Promise<void>
  queryResult: UseQueryResult<AxiosResponse, unknown>
}): JSX.Element => {
  return (
    <div className="flex flex-col mt-8">
      <div className="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
        <div className="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
          <table className="min-w-full">
            <thead>
              <TableHeader
                headerItems={headerItems}
                sortItem={sortItem}
                setSortItem={setSortItem}
                search={search}
              />
            </thead>

            {children}
          </table>
          {queryResult.isFetched && (
            <Pager search={search} pageItem={pageItem} />
          )}
        </div>
      </div>
    </div>
  )
}

利用方法

画面側でテーブルヘッダ表示用オブジェクト、ページャ表示用オブジェクト、ソート設定用オブジェクトなどフックで作成、検索処理を定義の上、コンポーネントに引き渡します。

components/page/index-page.tsx
export const IndexPage = (): JSX.Element => {
  const router = useRouter()
  const [keyword, setKeyword] = useState('')
  const [pageItem, setPageItem] = useState<PageItem>({
    ...Const.defaultPageValue,
  })
  const [sortItem, setSortItem] = useState<SortItem>({
    ...Const.sortDefaultValue,
  })

...(中略)...

  const pushState = async (page: number, sort?: SortItem): Promise<void> => {
    await router.push({
      query: {
        keyword,
        page,
        orderBy: sort?.key ?? sortItem.key,
        order: sort?.order ?? sortItem.order,
      },
    })
  }

  return (
    <>
    
...(中略)...

        <SearchableTable
          search={pushState}
          headerItems={headerItems}
          pageItem={pageItem}
          sortItem={sortItem}
          setSortItem={setSortItem}
          queryResult={products}
        >
	 <!-- テーブルデータは画面コンポーネントで定義する -->
          <tbody className="bg-white">
            {products.isFetched &&
              products.data.data.data.map((product: Product, index: number) => (
                <ProductRow key={index} product={product} />
              ))}
          </tbody>
        </SearchableTable>
      </div>
    </>
  )
}

管理画面のレイアウト定義用コンポーネント(Templates)

各画面で共通の見た目の設定(ヘッダ、サイドメニュー)やSEO用のメタタグの設定、各画面での共通処理(トースト表示、状態管理、非同期処理設定)などを定義のうえ、管理画面の各画面で利用します。

components/template/dashboard-layout.tsx
export const DashboardLayout = ({
  children,
  title,
}: {
  children: React.ReactNode
  title: string
}): JSX.Element => {
  const [sidebarOpen, setSidebarOpen] = useState(false)

  return (
    <>
      <QueryClientProvider client={queryClient}> <!-- 非同期処理設定 -->
        <ConfirmProvider> <!-- 確認ダイアログ表示の共通設定 -->
          <GlobalStateProvider> <!-- アプリ全体の状態管理 -->
            <Seo title={title} /> <!-- SEOタグを設定する -->
            <div className="flex h-screen bg-gray-200 font-roboto">
              <SideBar
                sidebarOpen={sidebarOpen}
                toggle={() => setSidebarOpen(false)}
              /> <!-- サイドメニュー -->
              <div className="flex-1 flex flex-col overflow-hidden">
                <Header toggle={() => setSidebarOpen(true)} /> <!-- ヘッダー -->
                <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-200">
                  {children}
                </main>
              </div>
            </div>
          </GlobalStateProvider>
          <ReactQueryDevtools initialIsOpen={false} />
        </ConfirmProvider>
      </QueryClientProvider>
      <ToastContainer
        autoClose={3000}
        hideProgressBar={false}
        newestOnTop={false}
        closeOnClick
        rtl={false}
        position={'bottom-right'}
        pauseOnFocusLoss
        draggable
        pauseOnHover
      /> <!-- トースト表示用 -->
    </>
  )
}

利用方法

公式のサンプル通りの方法で各画面で作成したレイアウトを利用します。

https://nextjs.org/docs/basic-features/layouts#with-typescript
pages/_app.tsx
import type { ReactElement, ReactNode } from 'react'
import type { NextPage } from 'next'
import type { AppProps } from 'next/app'

import 'react-toastify/dist/ReactToastify.css' // For Toast
import '../styles/global.css'

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode
}

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
}

export default function MyApp({
  Component,
  pageProps,
}: AppPropsWithLayout): ReactNode {
  // Use the layout defined at the page level, if available
  const getLayout = Component.getLayout ?? ((page) => page)

  return getLayout(<Component {...pageProps} />)
}
pages/index.tsx
import { ReactElement } from 'react'
import { DashboardLayout } from '../components/template'
import IndexPage from '../components/page/index-page'
import { GetServerSideProps } from 'next'
import { checkSession } from '../filters/checkSession'

export default function Index(): JSX.Element {
  return <IndexPage />
}

Index.getLayout = function getLayout(page: ReactElement) {
 // 作成したレイアウトコンポーネントを利用する
  return <DashboardLayout title={'トップページ'}>{page}</DashboardLayout>
}