[React.js/TypeScript] 管理画面系のサイドバー実装例
概要
今までたくさんの企業で管理画面やSaaSのUIを作ってきました。そのたびにサイドバーを実装してきましたが、さすがにそろそろテンプレート化しておこうと思った…というのがこの記事の執筆動機です。
この記事では、サイドバー実装のサンプルコードと、軽微な解説を掲載します。使用技術は React.js / TypeScript / CSS in JS になります。
Vue.js でも TailwindCSS でも、この記事で紹介した考え方自体は流用できると思います。
UI ライブラリは使用していません。管理画面やSaaS開発では、自前で作った方が拡張性があり、複雑なニーズに対応できるためです。
完成イメージ
ブログサービスを例にして、以下のような構造を持つサイドバーを作成していきます。
- 記事
- 記事一覧
- コメント一覧(通知の表示あり)
- 分析
- アクセス解析
- 購読者解析(説明文の表示あり)
- 各種設定
- アカウント設定
- メール配信設定
- サポート
- お問い合わせ
ネスト構造は2段。1段目をグループ、2段目をリンクと呼称します。
1段目をクリックすることで、2段目が展開されます。
グループにはアイコンを付けます。リンクには通知数や説明文の表示が含まれることがあります。
型定義
まずは型定義。名称はお好みで変えてもOK。 path
ではなく url
にするとか。
interface Group {
name: string;
icon: string;
links: Array<{
name: string;
path: string;
isExternal?: boolean; // 外部リンクかどうか
notificationKey?: string; // 未読数を引くための識別子
description?: string; // 説明文を表示する場合
}>
}
定数
次にサイドバーの内容を定数で定義します。分かりやすいようにアイコンには絵文字を指定していますが、実際のコードでは何かしらのSVGなどをインポートして設定することになるかと思います。
const groups: Array<Group> = [
{
name: '記事',
icon: '✒️',
links: [
{
name: '記事一覧',
path: '/articles',
},
{
name: 'コメント一覧',
path: '/comments',
notificationKey: 'comments',
}
]
},
{
name: '分析',
icon: '📈',
links: [
{
name: 'アクセス解析',
path: '/analytics',
},
{
name: '購読者解析',
path: '/subscribers',
description: 'これはΒ版の機能になります'
}
]
},
{
name: '各種設定',
icon: '⚙️',
links: [
{
name: 'アカウント設定',
path: '/account',
},
{
name: 'メール配信設定',
path: '/mail',
}
]
},
{
name: 'サポート',
icon: '🛠️',
links: [
{
name: 'お問い合わせ',
path: 'https://suppport.xxxxxxxxx.com/contact',
isExternal: true,
}
]
}
]
ここで大事なのは、この定数を static に保つことです。
この定数を上書きして、未読数などを埋め込みたくなるかもしれませんが、そうするとコードが複雑化します。
状態管理
次に状態管理です。今回は2つの状態を定義しています。
import { useState } from 'react';
// ここに型定義と定数定義
const Sidebar: React.FC = () = {
/**
* 展開しているグループのindexを保持
*
* <label > と <input type="checkbox" hidden /> と CSS の has(:checked) を使えば JavaScript 不要で実装できるが、CSS側が複雑になるのでやめておく
*/
const [openState, setOpenState] = useState<Set<number>>(new Set());
/**
* 通知数を保持
*
* APIフェッチしたデータなどを、useEffectなどでsetStateすればOK
* ReactQueryなどを使っている場合は、そのデータをそのまま使えばOK
*/
const [notifications, setNotifications] = useState<{ [key: string]: number }>({});
/**
* グループを展開/折りたたむ
*
* ビュー側のコードをシンプルにするため、Setのやり取りはここで定義しておく
*/
const toggleGroup = (index: number) => {
setOpenState(prev => {
const newState = new Set(prev);
newState.has(index) ? newState.delete(index) : newState.add(index);
return newState;
});
}
}
HTML・CSS
いよいよHTML・CSSです。まずはHTMLから。
import { useState, Fragment } from 'react';
// ここに型定義と定数定義
const Sidebar: React.FC = () => {
// ここに先ほど定義した状態管理系のコード
return (
<Wrapper>
{groups.map((group, index) => (
<Fragment key={group.name}>
<Group onClick={() => toggleGroup(index)}>
<Icon src={group.icon} alt={`${group.name}のアイコン`} />
{group.name}
<ToggleIcon open={openState.has(index)} />
</Group>
<Links open={openState.has(index)}>
{group.links.map((link, i) => (
<Fragment key={link.path}>
{link.isExternal ? (
// 外部リンク
<ExternalLink
href={link.path}
target="_blank"
rel="noopener"
// グループが閉じているときにタブのフォーカスを当てない
tabIndex={openState.has(index) ? 0 : -1}
>
{link.name}
</ExternalLink>
) : (
// SPA遷移
<Link
to={link.path}
// グループが閉じているときにタブのフォーカスを当てない
tabIndex={openState.has(index) ? 0 : -1}
>
{link.notificationKey && notifications[link.notificationKey] > 0 && (
<Notification>
{notifications[link.notificationKey]}
</Notification>
)}
{link.name}
</Link>
)}
{link.description && <Description>{link.description}</Description>}
</Fragment>
))}
</Links>
</Fragment>
))}
</Wrapper>
)
}
次にCSS。自分はemotionを使っていますが、他のCSSinJSライブラリでもそんなに変わりはないかなと思います。
import { Link as OriginalLink } from 'react-router-dom';
// サイドバー全体を包むラッパー。余白や背景色はお好みで。
const Wrapper = styled.div`
height: 100%;
padding: 4px 0 30px;
background-color: var(--bg-color);
overflow-y: auto;
`;
// グループ。展開・縮小を行うのでボタンとして定義。hoverするとふわっと色がつくようなスタイルになってます。
const Group = styled.button`
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
margin-top: 4px;
padding: 12px 14px;
border-radius: 0 30px 30px 0;
border: none;
background-color: transparent;
text-align: left;
font-size: var(--font-large);
font-weight: var(--font-bold);
cursor: pointer;
transition: background-color 0.2s ease-out;
&:hover {
background-color: var(--accent-color);
}
`;
const Icon = styled.img`
width: 20px;
height: 20px;
`;
// よくある「くの字」。展開・縮小を分かりやすくするため。
const ToggleIcon = styled.div<{ open: boolean }>`
position: relative;
margin-left: auto; // 右端に寄せる
&::before {
content: '';
width: 5px;
height: 5px;
border-top: solid 2px var(--gray);
border-right: solid 2px var(--gray);
position: absolute;
top: 0;
left: 0;
transform: ${(props) => (props.open ? 'rotate(135deg)' : 'rotate(45deg)')};
}
`;
const Links = styled.div<{ open: boolean }>`
display: flex;
flex-direction: column;
gap: 4px;
height: 0; // デフォルトで0pxの高さ
padding: ${(props) => (props.open ? '8px 4px 10px' : '0')}; // 展開時のみ余白
opacity: ${(props) => (props.open ? 1 : 0)}; // ふわっと表示
overflow: hidden;
transition: all 0.2s ease-out;
${(props) =>
props.open
? `
height: auto; /* fallback */
height: calc-size(auto, size); /* height: auto を transition させる */
`
: ''};
`;
// リンクテキストの共通スタイル
const linkStyle = css`
font-size: var(--font-medium);
font-weight: var(--font-semi-bold);
transition: opacity 0.1s ease-out;
&:hover {
opacity: 0.7; // ふわっと
}
`;
// SPAリンク
const Link = styled(OriginalLink)`
position: relative;
${linkStyle}
`;
// 外部リンク
const ExternalLink = styled.a`
${linkStyle}
`;
// 通知数のバッジ表示
const Notification = styled.span`
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 18px;
height: 18px;
background-color: var(--warn);
color: var(--white);
text-align: center;
border-radius: 50%;
font-weight: var(--font-bold);
font-size: var(--font-small);
`;
// 説明文
const Description = styled.p`
padding: 8px 12px;
background-color: var(--secondary);
border-radius: 10px;
font-size: var(--font-default);
line-height: 1.5;
`;
完成
すべてのパーツが出揃い、サイドバーが完成しました。
アイコン画像など、記事中の解説とは異なりますが、だいたい以下のような挙動になるかなと思います。
Discussion