Skip to main content

8.Atomコンポーネント

1つ前のアトミックデザインで紹介した Box, Clickable, Link, Text, Image, Svg, Motion, TextField, TextAreaを作成します。

まずは、プロジェクトに依存しないコンポーネントのatoms, moleculesを収めるatomicフォルダ作成します。

mkdir atomic
mkdir atomic/atoms
mkdir atomic/molecules

Atomコンポーネントのインポートを容易にするためにatomic/index.tsを作成しておきます。

touch atomic/index.ts

props警告回避

Emotionは未定義のpropsを渡すと警告を表示します。 特に、styled-systemは動的に各cssプロパティのpropsを渡すので、そこが問題となります。 これを回避するためstyled-systemが扱うprops定義をインストールして、Atomコンポーネントを作成するときに渡しておきます。

pnpm add @styled-system/should-forward-prop

具体的な使い方は各種コンポーネントを作成するときに解説します。

styled-systemの型情報を追加

styled-systemのtypesインストールします。

pnpm add -D @types/styled-system @types/styled-system__should-forward-prop

Atomコンポーネント

Box

Boxはdivタグで作られたコンポーネントで主に要素間のmarginpadding、配置などを担います。

touch atomic/atoms/Box.tsx
import styled from '@emotion/styled'

import {
compose,
space,SpaceProps,
color,ColorProps,
layout,LayoutProps,
flexbox,FlexboxProps,
border,BorderProps,
position,PositionProps,
} from 'styled-system'
import shouldForwardProp from '@styled-system/should-forward-prop'

interface BoxProps extends SpaceProps, ColorProps, LayoutProps, FlexboxProps, BorderProps, PositionProps {}

const composed = compose(space, color, layout, flexbox, border, position)
export const Box = styled('div', { shouldForwardProp })<BoxProps>(composed)

先ほどの用意したstyled-system/should-forward-propをstyled宣言時に追加することで、 styled-systemで使うpropsがEmotionのコンポーネントに引き渡されます。

Clickable

Clickableはdivタグで作られたコンポーネントです。 web標準で言うと、buttonタグを使いたいのですが、 デフォルトでバインドされているスタイルに癖がありすぎてdivタグで代用しています。

基本的には、onClickでトリガーします。

touch atomic/atoms/Clickable.tsx
import { Theme } from '@emotion/react'
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space,
SpaceProps,
color,
ColorProps,
layout,
LayoutProps,
flexbox,
FlexboxProps,
border,
BorderProps,
position,
PositionProps,
} from 'styled-system'

interface ClickableProps extends SpaceProps, ColorProps, LayoutProps, FlexboxProps, BorderProps, PositionProps {
hoverBgColor?: keyof Theme['colors']
hoverColor?: keyof Theme['colors']
hoverBorderColor?: keyof Theme['colors']
hoverWeight?: string
hoverShadow?: boolean
disabled?: boolean
disabledColor?: keyof Theme['colors']
disabledHoverBgColor?: keyof Theme['colors']
disabledBgColor?: keyof Theme['colors']
disabledHoverColor?: keyof Theme['colors']
}

export const Clickable = styled('div', { shouldForwardProp })<ClickableProps>`
${({ theme }) => theme.mediaQueries.nonMobile} {
&:hover {
cursor: pointer;
${({ hoverBgColor, theme }) => hoverBgColor && `background-color: ${theme.colors[hoverBgColor]};`};
${({ hoverWeight }) => hoverWeight && `font-weight: ${hoverWeight};`};
${({ hoverBorderColor, theme }) => hoverBorderColor && `border-color: ${theme.colors[hoverBorderColor]};`};
${({ hoverShadow, theme }) => hoverShadow && `box-shadow: 0 2px 4px 0 ${theme.rgbaColors.black(0.2)};`};
&,
& > * {
${({ hoverColor, theme }) => hoverColor && `color: ${theme.colors[hoverColor]};`};
}
}
}

${({ disabled }) => disabled && '&'} {
${({ disabledBgColor, theme }) => disabledBgColor && `background-color: ${theme.colors[disabledBgColor]};`};
&,
& > * {
${({ disabledColor, theme }) => disabledColor && `color: ${theme.colors[disabledColor]};`};
}
${({ theme }) => theme.mediaQueries.nonMobile} {
&:hover {
${({ disabledHoverBgColor, theme }) =>
disabledHoverBgColor && `background-color: ${theme.colors[disabledHoverBgColor]};`};
cursor: not-allowed;
}
}
}

${compose(space, color, layout, flexbox, border, position)}
`

基本はBoxと同じ構造をしています。 Clickable はクリックの特性であるhoverdisabledのスタイルを加えています。

hoverはカーソルがある環境でのみ動作する様になっています。 theme.mediaQueries.nonMobileを利用してスタイルを当てるか判定しています。

disabledtrueの時にスタイルが反映されるように、多少ややこしい処理になっています。

Link は2 つの処理をpropsを見て判断し返すコンポーネントです。 1 つはNext.js プロジェクトの別のページへ移動するためのコンポーネント。 もう1つは外部のリンクへ飛ぶためのコンポーネントです。

コンポーネントを切り替える処理はhrefがあるか無いかで判定して返します。 個人的には与えられたpropsによって返すコンポーネントが異なるのは引っかかりますが、 役割的には同じページの移動処理なので1つにまとめることにしました。

また、下のようにNext.jsのLinkをEmotionで利用する場合はネストして利用する必要があります。

https://nextjs.org/docs/api-reference/next/link#if-the-child-is-a-custom-component-that-wraps-an-a-tag

では、作っていきましょう。

touch atomic/atoms/Link.tsx
import { Theme } from '@emotion/react'
import NextLink from 'next/link'
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
color,
ColorProps,
space,
SpaceProps,
layout,
LayoutProps,
flexbox,
FlexboxProps,
border,
BorderProps,
position,
PositionProps,
} from 'styled-system'

interface StyledLinkProps extends SpaceProps, ColorProps, LayoutProps, FlexboxProps, BorderProps, PositionProps {
hoverBgColor?: keyof Theme['colors']
hoverWeight?: string
hoverBorderColor?: keyof Theme['colors']
hoverShadow?: keyof Theme['colors']
hoverUnderline?: boolean
hoverColor?: keyof Theme['colors']
hoverOpacity?: boolean
}

type Url = string

const StyledLink = styled(NextLink, { shouldForwardProp })<StyledLinkProps>`
${compose(space, color, layout, flexbox, border, position)};
text-decoration: none;

${({ theme }) => theme.mediaQueries.nonMobile} {
&:hover {
cursor: pointer;
${({ hoverBgColor, theme }) => hoverBgColor && `background-color: ${theme.colors[hoverBgColor]};`};
${({ hoverWeight }) => hoverWeight && `font-weight: ${hoverWeight};`};
${({ hoverBorderColor, theme }) => hoverBorderColor && `border-color: ${theme.colors[hoverBorderColor]};`};
${({ hoverShadow, theme }) => hoverShadow && `box-shadow: 0 2px 4px 0 ${theme.colors[hoverShadow]};`};
${({ hoverUnderline }) => hoverUnderline && `text-decoration: underline;`};
${({ hoverOpacity }) => hoverOpacity && `opacity: 0.8`};
&,
& > * {
${({ hoverColor, theme }) => hoverColor && `color: ${theme.colors[hoverColor]};`};
}
}
}
`
interface LinkProps extends Omit<React.ComponentProps<typeof StyledLink>, 'href'> {
href: React.ComponentProps<typeof NextLink>['href'] & URL
}

export function Link({ href, ...props }: LinkProps) {
if (typeof href === 'string' && !(href.startsWith('/') || href.startsWith('#'))) {
return <StyledLink as="a" href={href} target="_blank" rel="noopener noreferrer" {...props} />
}

return <StyledLink href={href} passHref {...props} />
}

Text

Text コンポーネントは全ての文字のスタイルを扱うコンポーネントです。 中には責務の線引きが曖昧のものもあります。 例えば、Linkコンポーネントにもcolorのプロパティがあります。 ただ、基本的にはLinkのchildrenにTextを置いてスタイルを当てる方が良さそうです。

touch atomic/atoms/Text.tsx
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space,SpaceProps,
typography,TypographyProps,
color,ColorStyleProps,
display,DisplayProps,
width,WidthProps,
} from 'styled-system'

interface TextProps extends SpaceProps, TypographyProps, ColorStyleProps, DisplayProps, WidthProps {}

export const Text = styled('div', { shouldForwardProp })<TextProps>`
font-family: Helvetica, sans-serif;
${compose(space, typography, color, display, width)}
`

Image

画像を表示するコンポーネント。Next.js にも Image コンポーネントが用意されており、そちらを拡張して利用する。 ただ、NextImageコンポーネントはかなり癖があるので、プロジェクトの利用に沿わない場合はhtmlのimgタグを拡張した方が良い。

https://nextjs.org/docs/pages/api-reference/components/image

Next.js が提供するNextImageコンポーネントは遅延ロード、画像の最適化、レスポンシブ対応が利用できる。

touch atomic/quanta/Image.js
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space, SpaceProps,
layout,LayoutProps,
border,BorderProps,
position,PositionProps,
} from 'styled-system'
import NextImage, { ImageProps as NextImageProps } from 'next/image'

interface NImageProps
extends SpaceProps,
LayoutProps,
Omit<NextImageProps, 'src' | 'width' | 'height'>,
BorderProps,
PositionProps {
src: NextImageProps['src']
width: NextImageProps['width']
height: NextImageProps['height']
}

const customShouldForwardProp = (prop: any) => ['width', 'height'].includes(prop) || shouldForwardProp(prop)

const NImage = styled(NextImage, { shouldForwardProp: customShouldForwardProp })<NImageProps>`
${compose(space, layout, border, position)}
`
interface OImageProps extends SpaceProps, LayoutProps, BorderProps, PositionProps {
src: string
}

const OImage = styled('img', { shouldForwardProp })<OImageProps>`
${compose(space, layout, border, position)}
`

interface ImageProps extends SpaceProps, LayoutProps, BorderProps, PositionProps {
src: NextImageProps['src']
width: NextImageProps['width'] | string
height: NextImageProps['height'] | string
alt: string
}

export function Image({ src, width, height, alt, ...props }: ImageProps) {
if (typeof src === 'string' && (typeof width === 'string' || typeof height === 'string')) {
return <OImage src={src} width={width} height={height} alt={alt} {...props} />
}
if (typeof width !== 'string' && typeof height !== 'string') {
return <NImage src={src} width={width} height={height} alt={alt} {...props} />
}
}

next.jsは画像を読み込む際に、基本的に許可したドメインのみを対象とします。 事前に登録していないドメインの画像を読み込もうとするとエラーが表示されます。

一旦、example.comを登録しておきましょう。

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
/* config options here */
compiler: {
emotion: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
pathname: '/**',
},
],
},
}

export default nextConfig

Svg

近年はアイコンやイラストを画像を使わずにsvgで表現することが増えています。 メニューの細かい描写をブラウザ依存させずに複雑な形状を表現するときに便利です。

Next.jsでsvgを利用するのはサードパティのライブラリを利用します。

Next.jsがsvgをimport できるように、webpack用のsvgrを使います。

このgithubでの投稿を参考にさせていただきました。

https://github.com/vercel/next.js/discussions/26167

pnpm add -D @svgr/webpack

next.config.ts開きsvgrの設定を加えます。 設定項目は2つあります。 1つはnextjsが使っているturbopackを、部分的にwebpackを使う設定。 もう1つが、nextjsでsvgを扱う設定です。

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
/* config options here */
compiler: {
emotion: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
pathname: '/**',
},
],
},
experimental: {
turbo: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
},
webpack(config) {
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg'))
config.module.rules.push({
test: /\.svg$/i,
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] },
use: ['@svgr/webpack'],
})
fileLoaderRule.exclude = /\.svg$/i
return config
},
}

export default nextConfig

svgファイルを扱うAtomコンポーネントを作成します。

touch atomic/atoms/Svg.tsx

まだ、svg ファイルを用意していないので諸々空の状態です。 <BaseSvg>でts-ignoreしているのはご愛嬌ということで。

atomic/atoms/Svg.tsx
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import { compose, space, SpaceProps, color, ColorProps, layout, LayoutProps, width, WidthProps } from 'styled-system'
const svgs: any = {}

interface BaseSvgProps extends SpaceProps, ColorProps, LayoutProps, WidthProps {}

export const BaseSvg = styled('svg', { shouldForwardProp })<BaseSvgProps>`
${compose(space, color, layout, width)}
`

interface SvgProps extends BaseSvgProps {
name: string
stroke?: string
}

export function Svg({ name, ...props }: SvgProps) {
// @ts-ignore
return <BaseSvg as={svgs[name]} {...props} />
}

Motion

Motion コンポーネントはmotionライブラリを利用したアニメーションを担うAtomコンポーネントです。

touch atomic/atom/Motion.tsx
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space,SpaceProps,
color,ColorProps,
layout,LayoutProps,
flexbox,FlexboxProps,
border,BorderProps,
position,PositionProps,
} from 'styled-system'
import { motion } from 'motion/react'

interface MotionBaseProps extends SpaceProps, ColorProps, LayoutProps, FlexboxProps, BorderProps, PositionProps {}

const MotionBase = styled('div', { shouldForwardProp })<MotionBaseProps>`
${compose(space, color, layout, flexbox, border, position)}
`
export const Motion = motion(MotionBase)

TextField

ユーザーの入力を受け付けるコンポーネントです。

touch atomic/atoms/TextField.tsx

input DOM 関連は User Agent StyleSheet が強く聞いているのでまずはそれをリセットし、 placeholder にも動的にスタイルを当てられるようにしている。

webkit-outer-spin-buttonは props で type="number"が指定された場合、右に出る数字の加減を操作できるパネルを非表示にしている。

import { Theme } from '@emotion/react'
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space,SpaceProps,
typography,TypographyProps,
color,ColorProps,
border,BorderProps,
width,WidthProps,
height,HeightProps,
} from 'styled-system'
import { InputHTMLAttributes } from 'react'

type BaseTextFieldProps = InputHTMLAttributes<HTMLInputElement> &
SpaceProps &
TypographyProps &
ColorProps &
BorderProps &
WidthProps &
HeightProps & {
fontColor?: keyof Theme['colors']
placeholderColor?: keyof Theme['colors']
}

export const BaseTextField = styled('input', { shouldForwardProp })<BaseTextFieldProps>`
${compose(space, typography, color, border, width, height)};
appearance: none;
outline: none;
background: none;

${({ fontColor, theme }) => fontColor && `color: ${theme.colors[fontColor]}`};
&::placeholder {
${({ placeholderColor, theme }) => placeholderColor && `color: ${theme.colors[placeholderColor]}`};
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
`
export const TextField: React.ComponentType<BaseTextFieldProps> = ({ type = 'text', ...props }) => {
return <BaseTextField type={type} {...props} />
}

TextArea

TextAreaのAtomコンポーネントも用意する。

touch atomic/atoms/TextArea.tsx
import { Theme } from '@emotion/react'
import styled from '@emotion/styled'
import shouldForwardProp from '@styled-system/should-forward-prop'
import {
compose,
space,SpaceProps,
typography,TypographyProps,
color,ColorProps,
border,BorderProps,
width,WidthProps,
height,HeightProps,
} from 'styled-system'

interface TextAreaProps extends SpaceProps, TypographyProps, ColorProps, BorderProps, WidthProps, HeightProps {
fontColor?: keyof Theme['colors']
placeholderColor?: keyof Theme['colors']
}

export const TextArea = styled('textarea', { shouldForwardProp })<TextAreaProps>`
margin: 0;
padding: 0;
border: none;
appearance: none;
outline: none;
box-shadow: none;
resize: none;
overflow: auto;
${compose(space, typography, color, border, width, height)};
${({ fontColor, theme }) => fontColor && `color: ${theme.colors[fontColor]}`};
&::placeholder {
${({ placeholderColor, theme }) => placeholderColor && `color: ${theme.colors[placeholderColor]}`};
}
`

まとめてエクスポート

最後にAtomコンポーネントをまとめて利用できるようにatomicフォルダにindex.ts ファイルを作り、そこからコンポーネントをexportします。

touch atomic/index.ts
atomic/index.ts
export * from './atoms/Box'
export * from './atoms/Clickable'
export * from './atoms/Link'
export * from './atoms/Text'
export * from './atoms/Image'
export * from './atoms/Svg'
export * from './atoms/Motion'
export * from './atoms/TextField'
export * from './atoms/TextArea'

使い方は、別の章で紹介します。

git add .
git commit -m "The Atom component in Atomic Design"