Reach UIを利用してPR TIMESのフロントエンドを実装した話

  • URLをコピーしました!

こんにちは、21新卒のフロントエンドのTepy(テッピー)です。

お読み頂いた方がいるかもしれませんが、先日こちらのReactの便利なライブラリーを紹介する記事を書きました。今回もReact業界での良いライブラリーをもう一つ紹介したいと思います。

本記事で紹介したいのは Reach UI という UI ライブラリーです。PR TIMESの企業詳細ページリプレイスプロジェクトにReach UIを利用していくつかのコンポーネントを作成しました。利用するきっかけや実装中の感想などの経験をお伝えらればと思います。

目次

Reach UIとは?

Reach UI seeks to become the accessible foundation of your React-based design system.

Reach UIのサイトから引用すると、Reach UIはReactベースのデザインシステムを作成するためのアクセシビリティを注目した基盤を目指しているライブラリーです。つまり、我々が作ろうとするデザインシステムのコンポーネントの基盤となる最低限のコンポーネントライブラリーとも言えます。

Reach UIと他のUIライブラリーの違い

Reach UIは UI の言葉が付いていますが、 Material UIChakra UIのような本格的な(完全な)UIライブラリーではないです。Material UI、Chakra UIなどのライブラリーはある程度のスタイルが入っているコンポーネントが存在し、利用者はそのままコンポーネントを利用してもスタイルが入ったような実装になります。一方で、Reach UIのコンポーネントはアクセシビリティとブラウザーのデフォルトのスタイル以外はほとんどスタイル無しのコンポーネントです。そのため、Reach UIをそのままに利用すると、スタイル無しの実装になります。もちろんスタイルカスタマイズは可能で、単純にできます。(実装のセクションに詳しく説明します)

なぜ Reach UI?

上にReach UIはそのまま利用できない(スタイル無しなので)と書いてありますが、実際にその逆です。個人開発などのソロプロジェクトには確かにMaterial UIやChakra UIのほうが効率が良いかもしれませんが、プロジェクトが大きくなりデザインシステムなどを作るつもりの場合、スタイル無しのようなReach UIの方がより有効的です。何故かというと、Material UIやChakra UIなどを利用して、デザインシステムを作ろうとすると、何処かにデフォルトスタイルのカスタマイズの壁にぶつかる可能性が高いです。もちろん、その壁を超える解決があると思いますが、その壁をぶつかる前に避ける方法があれば、その方法を選択すべきだと思います。その方法のーつはReach UIを利用するのです。

Reach UIのメリット

  • アクセシビリティが良い
  • スタイル無しでカスタマイズが柔軟
  • ウェブのベストプラクティスの機能を提供する
  • 構成可能なコンポーネント

Reach UIのデメリット

  • コンポーネントのタイプが少ない
  • スタイルがないのでスタイルを追加する必要がある

PR TIMESでのReach UIの実装事例

PR TIMESでは、Reactでリプレイスした企業詳細ページのプロジェクトとプレスキットという新機能追加プロジェクトにReach UIのコンポーネントを活用しました。利用したコンポーネントはTabListMenuです。

Tab

ざっくりの実装は以下になります。注目して欲しい所は Reach UIcomposable な所です。CompanyTabTabs, TabPanels, TabPanel, TabList, Tab のような小さいコンポーネントから作られています。アクセシビリティ用スタイル以外はスタイルが付いていないため、css={topTabPanels} のような emotion スタイルでカスタマイズできます。

また、以下の画像のように、CompanyTab のタブによってコンテンツの位置が違います。プレスリリースのタブだけは SearchBoxDropdown が入っています。普通の実装だと、active のタブのindex == 0 をチェックすれば、SearchBoxDropdown をプレスリリースタブだけに追加できますが、Reach UIのcomposable の特徴で(コンテンツをどこに配置しても同じ動作になります)、SearchBoxDropdownUIの並び順と同じように実装できます。

プレスリリースのタブ

ストーリーのタブ
import { Tabs, TabPanels, TabPanel, TabList, Tab } from '@reach/tabs';
import { SearchBox, TabTitle } from '@/components/pc';

export const CompanyTab = () => {
	return (
		<Tabs
		    index={currentTab}
				onChange={(index) => setCurrentTab(index)}
		>
			{/*
				SearchBoxとDropdownメニュー表示
				UIの並び順と同じように実装
			*/}
			<TabPanels css={topTabPanels}>
				<TabPanel css={topTabPanel}>
					<div css={topTabPanelWrapper}>
						<SearchBox />
		        <Dropdown />
					</div>
			</TabPanel>

			{/*
				Tabのタイトル
			*/}
			<div css={tabWrapper}>
				<TabList css={tabList}>
					<Tab>
						<TabTitle
							index={0}
		          title='プレスリリース'
		          count={totalPressReleasesCount}
						/>
					</Tab>
					<Tab>
						<TabTitle index={1} title='ストーリー' count={storiesCount} />
					</Tab>
					<Tab>
						<TabTitle index={2} title='プレスキット' />
					</Tab>
				</TabList>
				<RssShare />
			</div>

			{/*
				各Tabのコンテンツ
			*/}
			<TabPanels>
				<TabPanel css={tabContent}>
					...省略(1番目Tabのコンテンツ)
				</TabPanel>
				<TabPanel css={tabContent}>
					...省略(2番目Tabのコンテンツ)
				</TabPanel>
				<TabPanel css={tabContent}>
					...省略(3番目Tabのコンテンツ)
				</TabPanel>
			</TabPanels>
		</Tabs>
	); 
}

ListMenu

ListMenuTabと同じで composable コンポーネントです。しかし、今回はアクセシビリティのポイントを注目して欲しいです。

こちらのWAI-ARIAのガイドラインに従って、Reach UIのListMenuが実装されています。そのため、実装したDropdown コンポーネントもスクリーンリーダーやキーボードでの操作にきちんと対応するので、アクセシビリティが良いコンポーネントになります。また、以下の画像のように、下方向と上方向のDropdownが存在しますが、ーつのDropdownです。画面の下のスペースがDropdownのコンテンツの高さより小さい場合、Dropdownのコンテンツが上方向に展開するようになり、UX視点からでも良い挙動だと考えられます。

下方向のListMenu

上方向のListMenu
import {
  ListboxButton,
  ListboxInput,
  ListboxList,
  ListboxOption,
  ListboxPopover,
} from '@reach/listbox';
import { positionRight } from '@reach/popover';
import '@reach/listbox/styles.css';
import { AnimatePresence, motion } from 'framer-motion';

// Framer Motionにwrap
const MotionListboxPopover = motion(ListboxPopover, {
  forwardMotionProps: true,
});

export const DropdownMenu = () => {

	const onDropboxChange = (value: string) => {
    // データ取得など〜
  };

	return (
		<ListboxInput 
			value={value}
      onChange={onDropboxChange}
		>
			{({ isExpanded }) => (
        <div>
          <ListboxButton
            css={[
              listboxButton,
              variant === 'PC' ? listboxButtonPC : listboxButtonSP,
            ]}
            arrow={<ArrowIcon variant='gray' direction='down' />}
          >
            {title}
          </ListboxButton>
          <AnimatePresence>
            <MotionListboxPopover
              key='listboxPopover'
              data-testid='dropdown-popover'
              position={positionRight}
              css={[popOver, variant === 'PC' ? popOverPC : null]}
              initial='collapsed'
              animate={isExpanded ? 'open' : 'collapsed'}
              exit='collapsed'
              variants={popoverAnimation}
            >
              {isExpanded && (
                <ListboxList>
                  {options.map((option) => (
                    <ListboxOption
                      key={option.key}
                      css={[
                        listboxOption,
                        isSelected(option) ? selected : null,
                      ]}
                      value={option.value}
                      label={option.key}
                    >
                      <div css={optionWrapper}>
                        <DropdownCheckIcon
                          data-testid='dropdown-check-icon'
                          css={[
                            dropdownCheck,
                            isSelected(option)
                              ? css`
                                  visibility: visible;
                                `
                              : css`
                                  visibility: hidden;
                                `,
                          ]}
                        />
                        <span>{option.key}</span>
                      </div>
                    </ListboxOption>
                  ))}
                </ListboxList>
              )}
            </MotionListboxPopover>
          </AnimatePresence>
        </div>
      )}
		</ListboxInput>
	);
}

Reach UIのスタイリング

Reach UIのコンポーネントはアクセシビリティ用以外のスタイルが入っていないと言いましたが、スタイルについての注意点を伝えたいと思います。

Reach UIStyle Guide通り、スタイリングの選択が2つあります。

  1. ベーススタイルをinclude して、必要な部分のスタイルを上書きする(こちらを推奨する
  2. ベーススタイル無しで

ベーススタイルあり、スタイルを上書き

以下の実装のように、webpackを利用する場合、'@reach/listbox/styles.css' を直接にインポートして(styleの内容はこちらでみれます)、必要な部分のスタイルだけを上書きすることができます。直感で欠点だとみれるかもしれませんが、自分は良いトレードオフだと思います。なぜなら:

  • ベーススタイルはコンポーネントの挙動の最低限スタイルだけが入っている
  • 実際に上書きする部分は少なく、他のライブラリーを利用してもほぼ同じ部分を上書きしてスタイリングする必要がある(デザインシステムを実装する場合)
  • 以下のようなアクセシビリティのところをきちんとカバーしてくれる(スタイルはとにかく、開発者にアクセシビリティのポイントを促す)
[data-reach-listbox-popover]:focus-within {
  box-shadow: 0 0 4px Highlight;
  outline: -webkit-focus-ring-color auto 4px;
}

以下はDropdown で上書きした部分の例です。詳細の上書き方法としては、Reach UIのスタイリングセクションにご参考頂けます。

// @reach/listbox/styles.css
[data-reach-listbox-option][data-current-nav] {
	background: hsl(211, 81%, 46%);
	color: hsl(0, 0%, 100%);
}

// Dropdownの中
const listboxOption = css`
	&[data-current-nav] {
    background: ${colors.white};
    color: ${colors.text.primary};
  }
`;

ベーススタイル無し

ベーススタイルをインクルードしなくても実装できますが、コンポーネントを正しく動作するために、ベーススタイルにあるスタイルを各自で実装する必要があります(全てのスタイルを実装する必要がある可能性が高い)。また、以下の赤い枠のように、Reach UIはベーススタイル無しよりも上書き方法を推奨していると示します。自分も同じで上書き方法を推奨します。

Reach UIスタイリング方法の注意

まとめ

Reach UIの様々なメリットやデメリットを紹介しましたが、どのケースでReach UIを推奨するのが良いだと、以下のReach UIを推奨するケースを考えましたので、ご参考になればと思います。

Radix UIは新しく出てきたUIライブラリーでVercelなどの大手企業に利用されていて、自分も調査中なので、もし興味があれば調べてみるといいかもしれません)

Reach UIを推奨するケース
  • URLをコピーしました!

この記事を書いた人

PR TIMESのフロントエンドエンジニアです。
主にReactを書いています。最先端Frontend技術を試すのが好きです。
@TepyThai

目次