├── .gitignore
├── .prettierrc
├── app
├── scenes
│ ├── 0-start.js
│ ├── home.js
│ ├── 10-complete.js
│ ├── 1-desktop.js
│ ├── 2-toggleable.js
│ ├── 3-responsive.js
│ ├── 4-transition.js
│ ├── 5-fixed.js
│ ├── 6-backdrop.js
│ ├── 9-component.js
│ ├── 7-focus.js
│ └── 8-focustrap.js
├── index.css
├── index.js
├── index.html
├── hooks
│ └── useBreakpoint.js
└── components
│ ├── FocusTrap.js
│ ├── Transition.js
│ └── Menu.js
├── tailwind.config.js
├── netlify.toml
├── README.md
├── .eslintrc
├── .vscode
└── tasks.json
├── babel.config.js
├── functions
└── sample.js
├── package.json
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "arrowParens": "always",
4 | "singleQuote": true,
5 | "endOfLine": "auto"
6 | }
7 |
--------------------------------------------------------------------------------
/app/scenes/0-start.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function HomeScene() {
4 | return (
5 |
6 |
Home
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: [],
3 | theme: {
4 | extend: {
5 | margin: {
6 | '-full': '-100%',
7 | full: '100%',
8 | '-cat': '-100%',
9 | },
10 | },
11 | },
12 | variants: {},
13 | plugins: [],
14 | }
15 |
--------------------------------------------------------------------------------
/app/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss/base";
2 | @import "tailwindcss/components";
3 | @import "tailwindcss/utilities";
4 |
5 | html {
6 | font-family: 'Noto Sans TC', sans-serif;
7 | }
8 |
9 | body {
10 | margin: 0;
11 | }
12 |
13 | a {
14 | text-decoration: none;
15 | }
16 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import './index.css'
2 |
3 | import 'core-js/stable'
4 | import 'regenerator-runtime/runtime'
5 |
6 | import * as React from 'react'
7 | import ReactDOM from 'react-dom'
8 |
9 | import HomeScene from './scenes/home'
10 |
11 | function App() {
12 | return
13 | }
14 |
15 | ReactDOM.render(, document.getElementById('root'))
16 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Sliding Sidebar
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/hooks/useBreakpoint.js:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from 'react-responsive'
2 |
3 | import resolveConfig from 'tailwindcss/resolveConfig'
4 | import tailwindConfig from '../../tailwind.config'
5 | const Tailwind = resolveConfig(tailwindConfig)
6 |
7 | export default function useBreakpoint(breakpoint) {
8 | return useMediaQuery({
9 | query: `(min-width: ${Tailwind.theme.screens[breakpoint]})`,
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [[redirects]]
2 | from = "/home"
3 | to = "/index.html"
4 | status = 200
5 | force = true
6 |
7 | [build]
8 | command = "npm run build"
9 | publish = "dist/"
10 | function = "functions" # keep this committed in the repo
11 | # functions = "dist/api" # keep this active in dev
12 |
13 | [[headers]]
14 | for = "/*"
15 | [headers.values]
16 | Access-Control-Allow-Origin = "*"
17 | Access-Control-Allow-Headers = "Content-Type"
18 |
19 |
20 | [dev]
21 | framework = "#custom"
22 | command = "npm run serve:webpack"
23 | targetPort = 8080
24 | autoLaunch = true
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-boilerplate
2 | A simple boilerplate for bootstrapping a new React application
3 |
4 | # Usage
5 |
6 | Create a new respository based on this as a template
7 |
8 | Clone that repository to your local environment
9 |
10 | ```sh
11 | git clone https://github.com/jacobparis/react-boilerplate my-project
12 | ```
13 |
14 | Install node modules
15 |
16 | ```sh
17 | npm install
18 | ```
19 |
20 | Register the repository with Netlify
21 |
22 | Link
23 | # Commands
24 |
25 | * `npm run build` to compile and drop the bundle in the `dist` directory
26 |
27 | * `npm run serve` to start a local webserver
28 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "parser": "babel-eslint",
4 | "ecmaVersion": 2018,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "env": {
11 | "es6": true,
12 | "node": true,
13 | "browser": true
14 | },
15 | "extends": [
16 | "plugin:prettier/recommended",
17 | "eslint:recommended",
18 | "plugin:react/recommended",
19 | "plugin:react-hooks/recommended"
20 | ],
21 | "plugins": ["react"],
22 | "rules": {
23 | "semi": [
24 | 2,
25 | "never"
26 | ],
27 | "react/prop-types": "off"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/scenes/home.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Menu from '../components/Menu'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(true)
9 |
10 | const isStatic = useBreakpoint('sm')
11 |
12 | return (
13 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/app/scenes/10-complete.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Menu from '../components/Menu'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(true)
9 |
10 | const isStatic = useBreakpoint('sm')
11 |
12 | return (
13 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "type": "npm",
8 | "script": "test",
9 | "group": "test",
10 | "problemMatcher": [],
11 | "presentation": {
12 | "group": "groupA"
13 | }
14 | },
15 | {
16 | "type": "npm",
17 | "script": "serve",
18 | "problemMatcher": [],
19 | "presentation": {
20 | "group": "groupA"
21 | }
22 | },
23 | {
24 | "type": "npm",
25 | "script": "build",
26 | "group": "build",
27 | "problemMatcher": [],
28 | "presentation": {
29 | "group": "groupA"
30 | }
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | api.cache.using(() => process.env.NODE_ENV)
3 |
4 | return {
5 | presets: [
6 | [
7 | '@babel/preset-env',
8 | {
9 | useBuiltIns: 'entry',
10 | corejs: '3',
11 | targets: '> 1%, not dead',
12 | },
13 | ],
14 | ['@babel/preset-react'],
15 | ],
16 | plugins: [
17 | '@babel/plugin-proposal-optional-chaining',
18 | api.env('development') && 'react-refresh/babel',
19 | ].filter(Boolean),
20 | env: {
21 | test: {
22 | presets: [
23 | [
24 | '@babel/preset-env',
25 | {
26 | targets: {
27 | node: 10,
28 | },
29 | },
30 | ],
31 | '@babel/preset-react',
32 | ],
33 | },
34 | },
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/scenes/1-desktop.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function HomeScene() {
4 | return (
5 |
6 |
33 |
34 |
35 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/functions/sample.js:
--------------------------------------------------------------------------------
1 | // const { MongoClient } = require('mongodb')
2 | // const promiseRetry = require('promise-retry')
3 |
4 | // const querystring = require('querystring')
5 |
6 | exports.handler = async function sample(req) {
7 | /**
8 | * Handle the authorization header here
9 | */
10 |
11 | // const authorization = req.headers.authorization || ''
12 |
13 | // const [protocol, token] = authorization.split(' ')
14 | // if (protocol !== 'Bearer' || !validTokens.includes(token)) return {
15 | // headers: {
16 | // 'WWW-Authenticate': 'Bearer'
17 | // },
18 | // statusCode: 401
19 | // }
20 |
21 | /**
22 | * Connect to mongo here
23 | *
24 | * Make a new user on Atlas
25 | */
26 |
27 | // const mongo = new MongoClient('mongodb+srv://user:pass@cluster0-shifn.mongodb.net/test?retryWrites=true&w=majority', {
28 | // useNewUrlParser: true,
29 | // useUnifiedTopology: true,
30 | // })
31 |
32 | // await promiseRetry((retry, number) => {
33 | // console.info(`MongoClient connecting - attempt ${number}`)
34 |
35 | // return mongo.connect()
36 | // .catch(error => {
37 | // console.error(error)
38 |
39 | // retry()
40 | // })
41 | // .then(() => {
42 | // console.log('MongoClient connected successfully')
43 | // })
44 | // }, {
45 | // retries: 3,
46 | // minTimeout: 2000,
47 | // maxTimeout: 3000,
48 | // })
49 |
50 | return {
51 | statusCode: 200,
52 | headers: {
53 | 'content-type': 'application/json; charset=utf8',
54 | 'cache-control': 'max-age=36000, immutable'
55 | },
56 | body: JSON.stringify({
57 | name: 'Sample Function',
58 | healthy: true
59 | })
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/app/components/FocusTrap.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function FocusTrap({ children, isActive }) {
4 | const topTabTrap = React.useRef()
5 | const bottomTabTrap = React.useRef()
6 | const container = React.useRef()
7 |
8 | React.useEffect(() => {
9 | document.addEventListener('focusin', trapFocus)
10 |
11 | return () => document.removeEventListener('focusin', trapFocus)
12 |
13 | function trapFocus(event) {
14 | // Only trap focus in modal form
15 | if (!isActive) return
16 |
17 | let elements
18 | if (event.target === topTabTrap.current) {
19 | elements = getFocusableElements()
20 |
21 | if (elements.length > 0) {
22 | const lastElement = elements[elements.length - 1]
23 | lastElement.focus()
24 | }
25 | }
26 |
27 | if (event.target === bottomTabTrap.current) {
28 | elements = getFocusableElements()
29 |
30 | if (elements.length > 0) {
31 | const firstElement = elements[0]
32 | firstElement.focus()
33 | }
34 | }
35 | }
36 |
37 | function getFocusableElements() {
38 | if (!container.current) return []
39 |
40 | const FOCUSABLE_SELECTOR = [
41 | 'button',
42 | 'a[href]',
43 | 'input',
44 | 'select',
45 | 'textarea',
46 | '[tabindex]',
47 | '[contenteditable]',
48 | ]
49 | .map((selector) => `${selector}:not(:disabled):not([disabled])`)
50 | .join(', ')
51 |
52 | return Array.from(container.current.querySelectorAll(FOCUSABLE_SELECTOR))
53 | .filter((element) => element !== topTabTrap.current)
54 | .filter((element) => element !== bottomTabTrap.current)
55 | }
56 | }, [isActive, topTabTrap, bottomTabTrap, container])
57 |
58 | return (
59 |
60 | {isActive && }
61 | {children}
62 | {isActive && }
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-boilerplate",
3 | "version": "1.0.0",
4 | "description": "A simple boilerplate for bootstrapping a new React application",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "build": "npx webpack",
9 | "serve": "netlify dev --functions functions",
10 | "serve:webpack": "npx webpack-dev-server"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/jacobparis/react-boilerplate.git"
15 | },
16 | "author": "Jacob Paris",
17 | "license": "ISC",
18 | "bugs": {
19 | "url": "https://github.com/jacobparis/react-boilerplate/issues"
20 | },
21 | "homepage": "https://github.com/jacobparis/react-boilerplate#readme",
22 | "devDependencies": {
23 | "@babel/core": "^7.9.6",
24 | "@babel/plugin-proposal-optional-chaining": "^7.10.1",
25 | "@babel/preset-env": "^7.10.1",
26 | "@babel/preset-react": "^7.10.1",
27 | "@babel/runtime-corejs3": "^7.9.6",
28 | "@pmmmwh/react-refresh-webpack-plugin": "^0.3.3",
29 | "@tailwindcss/custom-forms": "^0.2.1",
30 | "autoprefixer": "^9.8.4",
31 | "babel-eslint": "^10.1.0",
32 | "babel-loader": "^8.1.0",
33 | "command-line-args": "^5.1.1",
34 | "core-js": "^3.6.5",
35 | "css-loader": "^3.5.3",
36 | "eslint": "^6.8.0",
37 | "eslint-config-prettier": "^6.11.0",
38 | "eslint-plugin-prettier": "^3.1.4",
39 | "eslint-plugin-react": "^7.20.0",
40 | "eslint-plugin-react-hooks": "^4.0.5",
41 | "html-webpack-plugin": "^4.3.0",
42 | "mini-css-extract-plugin": "^0.9.0",
43 | "netlify-cli": "^2.54.0",
44 | "node-sass": "^4.14.1",
45 | "prettier": "^2.0.5",
46 | "react-refresh": "^0.8.3",
47 | "sass-loader": "^8.0.2",
48 | "webpack": "^4.43.0",
49 | "webpack-cli": "^3.3.11",
50 | "webpack-dev-server": "^3.11.0"
51 | },
52 | "dependencies": {
53 | "@xstate/react": "^0.8.1",
54 | "postcss-loader": "^3.0.0",
55 | "prop-types": "^15.7.2",
56 | "react": "^16.13.1",
57 | "react-dom": "^16.13.1",
58 | "react-responsive": "^8.1.0",
59 | "react-transition-group": "^4.4.1",
60 | "tailwindcss": "^1.4.6",
61 | "xstate": "^4.10.0"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
2 | const HtmlWebpackPlugin = require('html-webpack-plugin')
3 | const webpack = require('webpack')
4 | const resolve = require('path').resolve
5 | // const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
6 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
7 |
8 | const isDevelopment = process.env.NODE_ENV !== 'production'
9 | module.exports = {
10 | mode: isDevelopment ? 'development' : 'production',
11 | entry: resolve(__dirname, 'app/index.js'),
12 | output: {
13 | path: resolve(__dirname, 'dist'),
14 | filename: '[name].[hash].js',
15 | publicPath: '/',
16 | },
17 | devServer: {
18 | contentBase: resolve(__dirname, 'dist'),
19 | hot: true,
20 | historyApiFallback: {
21 | index: 'index.html',
22 | },
23 | },
24 | module: {
25 | rules: [
26 | {
27 | test: /\.(js|jsx)$/,
28 | exclude: /node_modules/,
29 | use: 'babel-loader',
30 | },
31 | {
32 | test: /\.(png|jpg|gif)$/,
33 | use: [
34 | {
35 | loader: 'url-loader',
36 | options: {
37 | limit: 8192,
38 | },
39 | },
40 | ],
41 | },
42 | {
43 | test: /\.(s?css)$/,
44 | use: [
45 | MiniCssExtractPlugin.loader,
46 | 'css-loader',
47 | {
48 | loader: 'postcss-loader',
49 | options: {
50 | ident: 'postcss',
51 | plugins: [require('tailwindcss'), require('autoprefixer')],
52 | },
53 | },
54 | ],
55 | },
56 | ],
57 | },
58 | plugins: [
59 | new webpack.HashedModuleIdsPlugin(),
60 | new HtmlWebpackPlugin({
61 | template: './app/index.html',
62 | }),
63 | new MiniCssExtractPlugin({ filename: '[contenthash].css' }),
64 | // new BundleAnalyzerPlugin()
65 | isDevelopment && new ReactRefreshWebpackPlugin(),
66 | ].filter(Boolean),
67 | optimization: {
68 | runtimeChunk: 'single',
69 | splitChunks: {
70 | chunks: 'all',
71 | maxInitialRequests: Infinity,
72 | minSize: 0,
73 | cacheGroups: {
74 | vendor: {
75 | test: /[\\/]node_modules[\\/]/,
76 | name(module) {
77 | // get the name. E.g. node_modules/packageName/not/this/part.js
78 | // or node_modules/packageName
79 | const packageName = module.context.match(
80 | /[\\/]node_modules[\\/](.*?)([\\/]|$)/
81 | )[1]
82 |
83 | // npm package names are URL-safe, but some servers don't like @ symbols
84 | return `npm.${packageName.replace('@', '')}`
85 | },
86 | },
87 | },
88 | },
89 | },
90 | }
91 |
--------------------------------------------------------------------------------
/app/scenes/2-toggleable.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function HomeScene() {
4 | const [isClosed, setClosed] = React.useState(false)
5 |
6 | return (
7 |
8 | {!isClosed && (
9 |
39 | )}
40 |
41 |
42 |
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/app/scenes/3-responsive.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import useBreakpoint from '../hooks/useBreakpoint'
4 |
5 | export default function HomeScene() {
6 | const [isClosed, setClosed] = React.useState(false)
7 | const isStatic = useBreakpoint('sm')
8 |
9 | return (
10 |
11 | {(isStatic || !isClosed) && (
12 |
42 | )}
43 |
44 |
45 |
93 |
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/app/components/Transition.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Original source: https://gist.github.com/adamwathan/3b9f3ad1a285a2d1b482769aeb862467
3 | * Author: Adam Wathan
4 | *
5 | * I modified the script to not remove the enterTo and leaveTo classes upon completing the transition
6 | * Instead they're removed when the opposite transition begins
7 | */
8 | import { CSSTransition as ReactCSSTransition } from 'react-transition-group'
9 | import * as React from 'react'
10 |
11 | const TransitionContext = React.createContext({
12 | parent: {},
13 | })
14 |
15 | function useIsInitialRender() {
16 | const isInitialRender = React.useRef(true)
17 | React.useEffect(() => {
18 | isInitialRender.current = false
19 | }, [])
20 | return isInitialRender.current
21 | }
22 |
23 | function CSSTransition({
24 | show,
25 | enter = '',
26 | enterFrom = '',
27 | enterTo = '',
28 | leave = '',
29 | leaveFrom = '',
30 | leaveTo = '',
31 | appear,
32 | children,
33 | }) {
34 | const enterClasses = enter.split(' ').filter((s) => s.length)
35 | const enterFromClasses = enterFrom.split(' ').filter((s) => s.length)
36 | const enterToClasses = enterTo.split(' ').filter((s) => s.length)
37 | const leaveClasses = leave.split(' ').filter((s) => s.length)
38 | const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length)
39 | const leaveToClasses = leaveTo.split(' ').filter((s) => s.length)
40 |
41 | function addClasses(node, classes) {
42 | classes.length && node.classList.add(...classes)
43 | }
44 |
45 | function removeClasses(node, classes) {
46 | classes.length && node.classList.remove(...classes)
47 | }
48 |
49 | return (
50 | {
55 | node.addEventListener('transitionend', done, false)
56 | }}
57 | onEnter={(node) => {
58 | removeClasses(node, [...leaveToClasses])
59 | addClasses(node, [...enterClasses, ...enterFromClasses])
60 | }}
61 | onEntering={(node) => {
62 | removeClasses(node, [...enterFromClasses])
63 | addClasses(node, [...enterToClasses])
64 | }}
65 | onEntered={(node) => {
66 | removeClasses(node, [...enterClasses])
67 | }}
68 | onExit={(node) => {
69 | removeClasses(node, [...enterToClasses])
70 | addClasses(node, [...leaveClasses, ...leaveFromClasses])
71 | }}
72 | onExiting={(node) => {
73 | removeClasses(node, [...leaveFromClasses])
74 | addClasses(node, [...leaveToClasses])
75 | }}
76 | onExited={(node) => {
77 | removeClasses(node, [...leaveClasses])
78 | }}
79 | >
80 | {children}
81 |
82 | )
83 | }
84 |
85 | function Transition({ show, appear, ...rest }) {
86 | const { parent } = React.useContext(TransitionContext)
87 | const isInitialRender = useIsInitialRender()
88 | const isChild = show === undefined
89 |
90 | if (isChild) {
91 | return (
92 |
97 | )
98 | }
99 |
100 | return (
101 |
110 |
111 |
112 | )
113 | }
114 |
115 | export default Transition
116 |
--------------------------------------------------------------------------------
/app/scenes/4-transition.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(false)
9 | const isStatic = useBreakpoint('sm')
10 |
11 | return (
12 |
13 |
21 |
48 |
49 |
50 |
51 |
99 |
100 |
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/app/scenes/5-fixed.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(false)
9 | const isStatic = useBreakpoint('sm')
10 |
11 | return (
12 |
13 |
21 |
73 |
74 |
75 |
76 |
105 |
106 |
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/app/components/Menu.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from './Transition'
4 | import FocusTrap from './FocusTrap'
5 |
6 | export default function Menu({ children, isStatic, isClosed, setClosed }) {
7 | return (
8 |
9 |
17 |
71 |
72 |
73 |
83 |
84 |
85 |
86 |
87 |
88 | {!isStatic && (
89 |
109 | )}
110 |
111 | {children}
112 |
113 |
114 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/app/scenes/6-backdrop.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(false)
9 | const isStatic = useBreakpoint('sm')
10 |
11 | return (
12 |
13 |
21 |
74 |
75 |
76 |
86 |
87 |
88 |
89 |
90 |
119 |
120 |
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/app/scenes/9-component.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 | import FocusTrap from '../components/FocusTrap'
5 |
6 | import useBreakpoint from '../hooks/useBreakpoint'
7 |
8 | function Menu({ children, isStatic, isClosed, setClosed }) {
9 | return (
10 |
11 |
19 |
73 |
74 |
75 |
85 |
86 |
87 |
88 |
89 |
90 | {!isStatic && (
91 |
111 | )}
112 |
113 | {children}
114 |
115 |
116 |
117 | )
118 | }
119 |
120 | export default function HomeScene() {
121 | const [isClosed, setClosed] = React.useState(true)
122 |
123 | const isStatic = useBreakpoint('sm')
124 |
125 | return (
126 |
132 | )
133 | }
134 |
--------------------------------------------------------------------------------
/app/scenes/7-focus.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | export default function HomeScene() {
8 | const [isClosed, setClosed] = React.useState(true)
9 |
10 | const isStatic = useBreakpoint('sm')
11 |
12 | const topTabTrap = React.useRef()
13 | const bottomTabTrap = React.useRef()
14 |
15 | const firstFocusableElement = React.useRef()
16 | const lastFocusableElement = React.useRef()
17 |
18 | React.useEffect(() => {
19 | document.addEventListener('focusin', trapFocus)
20 |
21 | return () => document.removeEventListener('focusin', trapFocus)
22 |
23 | function trapFocus(event) {
24 | // Only trap focus in modal form
25 | if (isStatic) return
26 |
27 | if (event.target === topTabTrap.current) {
28 | lastFocusableElement.current.focus()
29 | }
30 |
31 | if (event.target === bottomTabTrap.current) {
32 | firstFocusableElement.current.focus()
33 | }
34 | }
35 | }, [isStatic, firstFocusableElement, lastFocusableElement])
36 |
37 | return (
38 |
39 |
47 |
106 |
107 |
108 |
118 |
119 |
120 |
121 |
122 |
151 |
152 |
153 | )
154 | }
155 |
--------------------------------------------------------------------------------
/app/scenes/8-focustrap.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Transition from '../components/Transition'
4 |
5 | import useBreakpoint from '../hooks/useBreakpoint'
6 |
7 | function FocusTrap({ children, isActive }) {
8 | const topTabTrap = React.useRef()
9 | const bottomTabTrap = React.useRef()
10 | const container = React.useRef()
11 |
12 | React.useEffect(() => {
13 | document.addEventListener('focusin', trapFocus)
14 |
15 | return () => document.removeEventListener('focusin', trapFocus)
16 |
17 | function trapFocus(event) {
18 | // Only trap focus in modal form
19 | if (!isActive) return
20 |
21 | let elements
22 | if (event.target === topTabTrap.current) {
23 | elements = getFocusableElements()
24 |
25 | if (elements.length > 0) {
26 | const lastElement = elements[elements.length - 1]
27 | lastElement.focus()
28 | }
29 | }
30 |
31 | if (event.target === bottomTabTrap.current) {
32 | elements = getFocusableElements()
33 |
34 | if (elements.length > 0) {
35 | const firstElement = elements[0]
36 | firstElement.focus()
37 | }
38 | }
39 | }
40 |
41 | function getFocusableElements() {
42 | if (!container.current) return []
43 |
44 | const FOCUSABLE_SELECTOR = [
45 | 'button',
46 | 'a[href]',
47 | 'input',
48 | 'select',
49 | 'textarea',
50 | '[tabindex]',
51 | '[contenteditable]',
52 | ]
53 | .map((selector) => `${selector}:not(:disabled):not([disabled])`)
54 | .join(', ')
55 |
56 | return Array.from(container.current.querySelectorAll(FOCUSABLE_SELECTOR))
57 | .filter((element) => element !== topTabTrap.current)
58 | .filter((element) => element !== bottomTabTrap.current)
59 | }
60 | }, [isActive, topTabTrap, bottomTabTrap, container])
61 |
62 | return (
63 |
64 | {isActive && }
65 | {children}
66 | {isActive && }
67 |
68 | )
69 | }
70 |
71 | export default function HomeScene() {
72 | const [isClosed, setClosed] = React.useState(true)
73 |
74 | const isStatic = useBreakpoint('sm')
75 |
76 | return (
77 |
78 |
86 |
140 |
141 |
142 |
152 |
153 |
154 |
155 |
156 |
185 |
186 |
187 | )
188 | }
189 |
--------------------------------------------------------------------------------