forked from crowetic/commerce
Focus trap and Modal Functionality (#145)
* Focus trap and Modal Functionality * Changes * Changes * Update components/ui/Modal/Modal.tsx Co-authored-by: Luis Alvarez D. <luis@vercel.com> * changes Co-authored-by: Luis Alvarez D. <luis@vercel.com>
This commit is contained in:
parent
86c396edd5
commit
66b8bdb6bb
@ -94,7 +94,7 @@ const CartSidebarView: FC = () => {
|
|||||||
My Cart
|
My Cart
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3">
|
<ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3">
|
||||||
{items.map((item) => (
|
{items.map((item: any) => (
|
||||||
<CartItem
|
<CartItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
@ -9,9 +9,11 @@ import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
|
|||||||
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
|
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
|
||||||
import { CartSidebarView } from '@components/cart'
|
import { CartSidebarView } from '@components/cart'
|
||||||
|
|
||||||
|
import LoginView from '@components/auth/LoginView'
|
||||||
import { CommerceProvider } from '@framework'
|
import { CommerceProvider } from '@framework'
|
||||||
import type { Page } from '@framework/api/operations/get-all-pages'
|
import type { Page } from '@framework/api/operations/get-all-pages'
|
||||||
|
|
||||||
|
|
||||||
const Loading = () => (
|
const Loading = () => (
|
||||||
<div className="w-80 h-80 flex items-center text-center justify-center p-3">
|
<div className="w-80 h-80 flex items-center text-center justify-center p-3">
|
||||||
<LoadingDots />
|
<LoadingDots />
|
||||||
@ -22,10 +24,6 @@ const dynamicProps = {
|
|||||||
loading: () => <Loading />,
|
loading: () => <Loading />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginView = dynamic(
|
|
||||||
() => import('@components/auth/LoginView'),
|
|
||||||
dynamicProps
|
|
||||||
)
|
|
||||||
const SignUpView = dynamic(
|
const SignUpView = dynamic(
|
||||||
() => import('@components/auth/SignUpView'),
|
() => import('@components/auth/SignUpView'),
|
||||||
dynamicProps
|
dynamicProps
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
@apply bg-primary p-12 border border-accents-2;
|
@apply bg-primary p-12 border border-accents-2 relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal:focus {
|
.modal:focus {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { FC, useRef, useEffect } from 'react'
|
import { FC, useRef, useEffect, useCallback } from 'react'
|
||||||
import Portal from '@reach/portal'
|
import Portal from '@reach/portal'
|
||||||
import s from './Modal.module.css'
|
import s from './Modal.module.css'
|
||||||
import { Cross } from '@components/icons'
|
import { Cross } from '@components/icons'
|
||||||
@ -7,45 +7,53 @@ import {
|
|||||||
enableBodyScroll,
|
enableBodyScroll,
|
||||||
clearAllBodyScrollLocks,
|
clearAllBodyScrollLocks,
|
||||||
} from 'body-scroll-lock'
|
} from 'body-scroll-lock'
|
||||||
|
import FocusTrap from '@lib/focus-trap'
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string
|
className?: string
|
||||||
children?: any
|
children?: any
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onEnter?: () => void | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: FC<Props> = ({ children, open, onClose }) => {
|
const Modal: FC<Props> = ({ children, open, onClose, onEnter = null }) => {
|
||||||
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
|
const ref = useRef() as React.MutableRefObject<HTMLDivElement>
|
||||||
|
|
||||||
|
const handleKey = (e: KeyboardEvent) =>
|
||||||
|
useCallback(() => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
return onClose()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
if (open) {
|
if (open) {
|
||||||
disableBodyScroll(ref.current)
|
disableBodyScroll(ref.current)
|
||||||
|
window.addEventListener('keydown', handleKey)
|
||||||
} else {
|
} else {
|
||||||
enableBodyScroll(ref.current)
|
enableBodyScroll(ref.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKey)
|
||||||
clearAllBodyScrollLocks()
|
clearAllBodyScrollLocks()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, handleKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className={s.root} ref={ref}>
|
<div className={s.root}>
|
||||||
<div className={s.modal}>
|
<div className={s.modal} role="dialog" ref={ref}>
|
||||||
<div className="h-7 flex items-center justify-end w-full">
|
<button
|
||||||
<button
|
onClick={() => onClose()}
|
||||||
onClick={() => onClose()}
|
aria-label="Close panel"
|
||||||
aria-label="Close panel"
|
className="hover:text-gray-500 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6"
|
||||||
className="hover:text-gray-500 transition ease-in-out duration-150 focus:outline-none"
|
>
|
||||||
>
|
<Cross className="h-6 w-6" />
|
||||||
<Cross className="h-6 w-6" />
|
</button>
|
||||||
</button>
|
<FocusTrap focusFirst>{children}</FocusTrap>
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
64
lib/focus-trap.tsx
Normal file
64
lib/focus-trap.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, RefObject } from 'react'
|
||||||
|
import { tabbable } from 'tabbable'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode | any
|
||||||
|
focusFirst?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FocusTrap({ children, focusFirst = false }: Props) {
|
||||||
|
const root: RefObject<any> = React.useRef()
|
||||||
|
const anchor: RefObject<any> = React.useRef(document.activeElement)
|
||||||
|
|
||||||
|
const returnFocus = () => {
|
||||||
|
// Returns focus to the last focused element prior to trap.
|
||||||
|
if (anchor) {
|
||||||
|
anchor.current.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trapFocus = () => {
|
||||||
|
// Focus the container element
|
||||||
|
if (root.current) {
|
||||||
|
root.current.focus()
|
||||||
|
if (focusFirst) {
|
||||||
|
selectFirstFocusableEl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFirstFocusableEl = () => {
|
||||||
|
// Try to find focusable elements, if match then focus
|
||||||
|
// Up to 6 seconds of load time threshold
|
||||||
|
let match = false
|
||||||
|
let end = 60 // Try to find match at least n times
|
||||||
|
let i = 0
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!match !== i > end) {
|
||||||
|
match = !!tabbable(root.current).length
|
||||||
|
if (match) {
|
||||||
|
// Attempt to focus the first el
|
||||||
|
tabbable(root.current)[0].focus()
|
||||||
|
}
|
||||||
|
i = i + 1
|
||||||
|
} else {
|
||||||
|
// Clear interval after n attempts
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(trapFocus, 20)
|
||||||
|
return () => {
|
||||||
|
returnFocus()
|
||||||
|
}
|
||||||
|
}, [root, children])
|
||||||
|
|
||||||
|
return React.createElement('div', {
|
||||||
|
ref: root,
|
||||||
|
children,
|
||||||
|
className: 'outline-none focus-trap',
|
||||||
|
tabIndex: -1,
|
||||||
|
})
|
||||||
|
}
|
@ -65,7 +65,8 @@
|
|||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
"react-merge-refs": "^1.1.0",
|
"react-merge-refs": "^1.1.0",
|
||||||
"react-ticker": "^1.2.2",
|
"react-ticker": "^1.2.2",
|
||||||
"swr": "^0.3.11",
|
"swr": "^0.4.0",
|
||||||
|
"tabbable": "^5.1.5",
|
||||||
"tailwindcss": "^1.9"
|
"tailwindcss": "^1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -8,6 +8,11 @@ module.exports = {
|
|||||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||||
'./components/**/*.{js,ts,jsx,tsx}',
|
'./components/**/*.{js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
|
options: {
|
||||||
|
safelist: {
|
||||||
|
standard: ['outline-none'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user