🐥

MUI (Material UI) v5 で App Shell をつくる

15 min read

はじめに

2021 年 9 月、MUI (旧 material-ui) v5 がリリースされました。
これを使って App Shell を作ってみます。

https://mui.com/

Material UI が MUI へと変更された経緯や変更点などについては、よしさんによる記事が詳しいです。

https://zenn.dev/h_yoshikawa0724/articles/2021-09-26-material-ui-v5

App Shell is 何?

↓ こんなやつのことです。

https://developers.google.com/web/fundamentals/architecture/app-shell?hl=ja

MUI をインストール

以下の環境でやってます。

zsh
# npm v6.x の場合
% npm init vite zenn-mui --template react-ts

# npm v7+ の場合
% npm init vite zenn-mui -- --template react-ts

% cd zenn-mui
% npm install
% npm run dev

公式のガイドにしたがって、必要なライブラリをインストールします。

https://mui.com/getting-started/installation/

本体とスタイリングエンジン

zsh
% npm i @mui/material @emotion/react @emotion/styled

MUI v5 からデフォルトのスタイリングエンジンが Emotion になりました。

https://emotion.sh/docs/introduction

従来通り、styled-components を使いたい場合はこちらのガイドをどうぞ。

https://mui.com/guides/styled-engine/

Roboto フォント

zsh
% npm install @fontsource/roboto

src/main.tsx を編集。

src/main.tsx
import ReactDOM from 'react-dom';

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';

const App = () => {
  return <div />;
};

ReactDOM.render(<App />, document.getElementById('root'));

SVG アイコン

zsh
% npm install @mui/icons-material

App Bar

https://mui.com/components/app-bar/

基本的には、公式のコードサンプルをコピペしていきます(以下、同様)。

src/ButtonAppBar.tsx
import {
  Box,
  Button,
  AppBar,
  Toolbar,
  IconButton,
  Typography,
} from '@mui/material';
import { Menu } from '@mui/icons-material';

export const ButtonAppBar = () => {
  return (
    <Box sx={{ flexGrow: 1 }}>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            size="large"
            edge="start"
            color="inherit"
            aria-label="menu"
            sx={{ mr: 2 }}
          >
            <Menu />
          </IconButton>
          <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
            News
          </Typography>
          <Button color="inherit">Login</Button>
        </Toolbar>
      </AppBar>
    </Box>
  );
};

親コンポーネントにインポート。

src/main.tsx
import { ButtonAppBar } from './ButtonAppBar';

const App = () => {
  return <ButtonAppBar />;
};

上下左右に隙間が空いてしまいました。自動では CSS リセットしてくれないようです。

https://coliss.com/articles/build-websites/operation/css/css-reset-for-modern-browser.html

GlobalStyles API

親コンポーネントへ GlobalStyles をインポートします。

https://mui.com/api/global-styles/
src/main.tsx
import { GlobalStyles } from '@mui/material';

const App = () => {
  return (
    <>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ButtonAppBar />
    </>
  );
};

Drawer

よくあるドロワーを作ります。

https://mui.com/components/drawers/
src/TemporaryDrawer.tsx
import {
  Box,
  List,
  Drawer,
  Divider,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import { Inbox, Mail } from '@mui/icons-material';

type Props = {
  open: boolean;
  onClose: () => void;
};

export const TemporaryDrawer = (props: Props) => {
  return (
    <Drawer open={props.open} onClose={props.onClose}>
      <Box sx={{ width: 250 }} role="presentation">
        <List>
          {['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
            <ListItem button key={text} onClick={props.onClose}>
              <ListItemIcon>
                {index % 2 === 0 ? <Inbox /> : <Mail />}
              </ListItemIcon>
              <ListItemText primary={text} />
            </ListItem>
          ))}
        </List>
        <Divider />
      </Box>
    </Drawer>
  );
};

ドロワーの開閉状態を管理するステートを追加する

親コンポーネントへステートを追加し、そのステートを反転させるコールバック関数も作ります。

src/main.tsx
import { useState } from 'react';
src/main.tsx
  const [drawerOpen, setDrawerOpen] = useState(false);
  const toggleDrawer = () => setDrawerOpen(!drawerOpen);

ButtonAppBar のメニューボタンに紐づける

変更箇所のみ記載(以下、同様)。

src/ButtonAppBar.tsx
+ type Props = {
+   onOpen: () => void;
+ };

- export const ButtonAppBar = () => {
+ export const ButtonAppBar = (props: Props) => {

           <IconButton
             size="large"
             edge="start"
             color="inherit"
             aria-label="menu"
             sx={{ mr: 2 }}
+            onClick={props.onOpen}
           >
src/main.tsx
     <>
       <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
 -      <ButtonAppBar />
+      <ButtonAppBar onOpen={toggleDrawer} />
     </>

親コンポーネントへ TemporaryDrawer をインポートする

src/main.tsx
import { TemporaryDrawer } from './TemporaryDrawer';
src/main.tsx
  return (
    <>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ButtonAppBar onOpen={toggleDrawer} />
      <TemporaryDrawer open={drawerOpen} onClose={toggleDrawer} />
    </>
  );

styled()

コンテンツ本体をラップするコンテナーを作ります。

https://mui.com/system/styled/

カスタムパーツは React コンポーネントの外側で作ります。

src/main.tsx
import { styled } from '@mui/material/styles';

const Container = styled('div')({
  margin: 0,
  padding: '1em',
});

// ~ snip ~

    <>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ButtonAppBar onOpen={toggleDrawer} />
      <TemporaryDrawer open={drawerOpen} onClose={toggleDrawer} />
      <Container></Container>
    </>

Dialog

https://mui.com/components/dialogs/
src/SimpleDialog.tsx
import {
  List,
  Dialog,
  Avatar,
  ListItem,
  DialogTitle,
  ListItemText,
  ListItemAvatar,
} from '@mui/material';
import { Add } from '@mui/icons-material';

type Props = {
  open: boolean;
  onClose: () => void;
};

export const SimpleDialog = (props: Props) => {
  return (
    <Dialog onClose={props.onClose} open={props.open}>
      <DialogTitle>Set backup account</DialogTitle>
      <List sx={{ pt: 0 }}>
        <ListItem autoFocus button onClick={props.onClose}>
          <ListItemAvatar>
            <Avatar>
              <Add />
            </Avatar>
          </ListItemAvatar>
          <ListItemText primary="Add account" />
        </ListItem>
      </List>
    </Dialog>
  );
};

Button

ダイアログを開くボタンを作ります。

https://mui.com/components/buttons/
src/main.tsx
import { GlobalStyles, Button } from '@mui/material';

// ~ snip ~

      <Container>
        <Button color="secondary" variant="outlined">
          Open Dialog
        </Button>
      </Container>

ダイアログの開閉状態を管理するステートを追加する

ドロワーと同様にステートとコールバック関数を用意します。

src/main.tsx
  const [dialogOpen, setDialogOpen] = useState(false);
  const toggleDialog = () => setDialogOpen(!dialogOpen);

  // ~ snip ~

      <Container>
        <Button color="secondary" variant="outlined" onClick={toggleDialog}>
          Open Dialog
        </Button>
      </Container>

  // ~ snip ~

親コンポーネントへ SimpleDialog をインポートする

src/main.tsx
import { SimpleDialog } from './SimpleDialog';

  // ~ snip ~

      <Container>
        <SimpleDialog open={dialogOpen} onClose={toggleDialog} />
        <Button color="secondary" variant="outlined" onClick={toggleDialog}>
          Open Dialog
        </Button>
      </Container>

  // ~ snip ~

Theming

カラーパレットを MUI v4 風に変更します。

https://mui.com/customization/theming/

https://material.io/resources/color/

テーマも React コンポーネントの外側で作ります。

src/main.tsx
// createTheme, ThemeProvider のインポート
import { styled, createTheme, ThemeProvider } from '@mui/material/styles';

// 色をインポート
import { indigo, pink } from '@mui/material/colors';

// テーマ
const theme = createTheme({
  palette: {
    primary: {
      main: indigo['500'],
      light: '#757de8',
      dark: '#002984',
    },
    secondary: {
      main: pink['500'],
      light: '#ff6090',
      dark: '#b0003a',
    },
  },
});

フラグメント <> ~ </> の代わりに ThemeProvider で JSX 全体をラップします。

src/main.tsx
    <ThemeProvider theme={theme}>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ButtonAppBar onOpen={toggleDrawer} />
      <TemporaryDrawer open={drawerOpen} onClose={toggleDrawer} />
      <Container>
        <SimpleDialog open={dialogOpen} onClose={toggleDialog} />
        <Button color="secondary" variant="outlined" onClick={toggleDialog}>
          Open Dialog
        </Button>
      </Container>
    </ThemeProvider>

ソースコード全文

ButtonAppBar.tsx
import {
  Box,
  Button,
  AppBar,
  Toolbar,
  IconButton,
  Typography,
} from '@mui/material';
import { Menu } from '@mui/icons-material';

type Props = {
  onOpen: () => void;
};

export const ButtonAppBar = (props: Props) => {
  return (
    <Box sx={{ flexGrow: 1 }}>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            size="large"
            edge="start"
            color="inherit"
            aria-label="menu"
            sx={{ mr: 2 }}
            onClick={props.onOpen}
          >
            <Menu />
          </IconButton>
          <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
            News
          </Typography>
          <Button color="inherit">Login</Button>
        </Toolbar>
      </AppBar>
    </Box>
  );
};
TemporaryDrawer.tsx
import {
  Box,
  List,
  Drawer,
  Divider,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import { Inbox, Mail } from '@mui/icons-material';

type Props = {
  open: boolean;
  onClose: () => void;
};

export const TemporaryDrawer = (props: Props) => {
  return (
    <Drawer open={props.open} onClose={props.onClose}>
      <Box sx={{ width: 250 }} role="presentation">
        <List>
          {['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
            <ListItem button key={text} onClick={props.onClose}>
              <ListItemIcon>
                {index % 2 === 0 ? <Inbox /> : <Mail />}
              </ListItemIcon>
              <ListItemText primary={text} />
            </ListItem>
          ))}
        </List>
        <Divider />
      </Box>
    </Drawer>
  );
};
SimpleDialog.tsx
import {
  List,
  Dialog,
  Avatar,
  ListItem,
  DialogTitle,
  ListItemText,
  ListItemAvatar,
} from '@mui/material';
import { Add } from '@mui/icons-material';

type Props = {
  open: boolean;
  onClose: () => void;
};

export const SimpleDialog = (props: Props) => {
  return (
    <Dialog onClose={props.onClose} open={props.open}>
      <DialogTitle>Set backup account</DialogTitle>
      <List sx={{ pt: 0 }}>
        <ListItem autoFocus button onClick={props.onClose}>
          <ListItemAvatar>
            <Avatar>
              <Add />
            </Avatar>
          </ListItemAvatar>
          <ListItemText primary="Add account" />
        </ListItem>
      </List>
    </Dialog>
  );
};
main.tsx
import { useState } from 'react';
import ReactDOM from 'react-dom';

import { GlobalStyles, Button } from '@mui/material';

import { indigo, pink } from '@mui/material/colors';
import { styled, createTheme, ThemeProvider } from '@mui/material/styles';

import { ButtonAppBar } from './ButtonAppBar';
import { SimpleDialog } from './SimpleDialog';
import { TemporaryDrawer } from './TemporaryDrawer';

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';

const Container = styled('div')({
  margin: 0,
  padding: '1em',
});

const theme = createTheme({
  palette: {
    primary: {
      main: indigo['500'],
      light: '#757de8',
      dark: '#002984',
    },
    secondary: {
      main: pink['500'],
      light: '#ff6090',
      dark: '#b0003a',
    },
  },
});

const App = () => {
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [dialogOpen, setDialogOpen] = useState(false);

  const toggleDrawer = () => setDrawerOpen(!drawerOpen);
  const toggleDialog = () => setDialogOpen(!dialogOpen);

  return (
    <ThemeProvider theme={theme}>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ButtonAppBar onOpen={toggleDrawer} />
      <TemporaryDrawer open={drawerOpen} onClose={toggleDrawer} />
      <Container>
        <SimpleDialog open={dialogOpen} onClose={toggleDialog} />
        <Button color="secondary" variant="outlined" onClick={toggleDialog}>
          Open Dialog
        </Button>
      </Container>
    </ThemeProvider>
  );
};

ReactDOM.render(<App />, document.getElementById('root'));

こちらもどうぞ

https://zenn.dev/sprout2000/books/76a279bb90c3f3

Discussion

ログインするとコメントできます