Repository: zeit-ui/react (ts)

Issue:
Make the modal accessible
<!-- Please do NOT DELETE the template. -->
<!-- No template issues will be closed. -->

# Feature request 🚀

  - [ ] I will create Pull Request
  - [x] It's just a suggestion

### Expected

  - Currently the modal component is not accessible, meaning if you use the keyboard to navigate (e.g. with `tab`) it will navigate the page in the background instead.
  - The modal component should capture keyboard focus and have proper aria attributes.



Pull request:
feat(modal): lock tab action inside modal
## Checklist

- [x] Fix linting errors
- [x] Tests have been added / updated (or snapshots)

## Change information

resolved #352 

Patch:
diff --git a/components/button/button.tsx b/components/button/button.tsx
index 651f9f5..9da127d 100644
--- a/components/button/button.tsx
+++ b/components/button/button.tsx
@@ -182,7 +182,8 @@ const Button = React.forwardRef<HTMLButtonElement, React.PropsWithChildren<Butto
             --zeit-ui-button-bg: ${bg};
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: ${hover.color};
             --zeit-ui-button-color: ${hover.color};
             background-color: ${hover.bg};
diff --git a/components/modal/modal-action.tsx b/components/modal/modal-action.tsx
index ddcae3e..ce06339 100644
--- a/components/modal/modal-action.tsx
+++ b/components/modal/modal-action.tsx
@@ -71,7 +71,8 @@ const ModalAction = React.forwardRef<HTMLButtonElement, React.PropsWithChildren<
         height: 100%;
         border-radius: 0;
       }
-      button.btn:hover {
+      button.btn:hover,
+      button.btn:focus {
         color: ${disabled ? color : theme.palette.foreground};
         background-color: ${disabled ? bgColor : theme.palette.accents_1};
       }
diff --git a/components/modal/modal-wrapper.tsx b/components/modal/modal-wrapper.tsx
index 31fae73..35e79b3 100644
--- a/components/modal/modal-wrapper.tsx
+++ b/components/modal/modal-wrapper.tsx
@@ -1,7 +1,8 @@
-import React from 'react'
+import React, { useEffect, useRef } from 'react'
 import withDefaults from '../utils/with-defaults'
 import useTheme from '../styles/use-theme'
 import CSSTransition from '../shared/css-transition'
+import { isChildElement } from '../utils/collections'
 
 interface Props {
   className?: string
@@ -24,11 +25,45 @@ const ModalWrapper: React.FC<React.PropsWithChildren<ModalWrapperProps>> = ({
   ...props
 }) => {
   const theme = useTheme()
+  const modalContent = useRef<HTMLDivElement>(null)
+  const tabStart = useRef<HTMLDivElement>(null)
+  const tabEnd = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    if (!visible) return
+    const activeElement = document.activeElement
+    const isChild = isChildElement(modalContent.current, activeElement)
+    if (isChild) return
+    tabStart.current && tabStart.current.focus()
+  }, [visible])
+
+  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
+    const isTabDown = event.keyCode === 9
+    if (!visible || !isTabDown) return
+    const activeElement = document.activeElement
+    if (event.shiftKey) {
+      if (activeElement === tabStart.current) {
+        tabEnd.current && tabEnd.current.focus()
+      }
+    } else {
+      if (activeElement === tabEnd.current) {
+        tabStart.current && tabStart.current.focus()
+      }
+    }
+  }
 
   return (
     <CSSTransition name="wrapper" visible={visible} clearTime={300}>
-      <div className={`wrapper ${className}`} {...props}>
+      <div
+        className={`wrapper ${className}`}
+        role="dialog"
+        tabIndex={-1}
+        onKeyDown={onKeyDown}
+        ref={modalContent}
+        {...props}>
+        <div tabIndex={0} className="hide-tab" aria-hidden="true" ref={tabStart} />
         {children}
+        <div tabIndex={0} className="hide-tab" aria-hidden="true" ref={tabEnd} />
         <style jsx>{`
           .wrapper {
             max-width: 90vw;
@@ -45,6 +80,7 @@ const ModalWrapper: React.FC<React.PropsWithChildren<ModalWrapperProps>> = ({
             padding: ${theme.layout.gap};
             box-shadow: ${theme.expressiveness.shadowLarge};
             opacity: 0;
+            outline: none;
             transform: translate3d(0px, -30px, 0px);
             transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s,
               transform 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s;
@@ -69,6 +105,14 @@ const ModalWrapper: React.FC<React.PropsWithChildren<ModalWrapperProps>> = ({
             opacity: 0;
             transform: translate3d(0px, -30px, 0px);
           }
+
+          .hide-tab {
+            outline: none;
+            overflow: hidden;
+            width: 0;
+            height: 0;
+            opacity: 0;
+          }
         `}</style>
       </div>
     </CSSTransition>
diff --git a/components/shared/backdrop.tsx b/components/shared/backdrop.tsx
index a74ad17..425f1b9 100644
--- a/components/shared/backdrop.tsx
+++ b/components/shared/backdrop.tsx
@@ -12,13 +12,13 @@ interface Props {
 const defaultProps = {
   onClick: () => {},
   visible: false,
-  offsetY: 0,
 }
 
-export type BackdropProps = Props & typeof defaultProps
+type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
+export type BackdropProps = Props & typeof defaultProps & NativeAttrs
 
 const Backdrop: React.FC<React.PropsWithChildren<BackdropProps>> = React.memo(
-  ({ children, onClick, visible }) => {
+  ({ children, onClick, visible, ...props }) => {
     const theme = useTheme()
     const [, setIsContentMouseDown, IsContentMouseDownRef] = useCurrentState(false)
     const clickHandler = (event: MouseEvent<HTMLElement>) => {
@@ -38,7 +38,7 @@ const Backdrop: React.FC<React.PropsWithChildren<BackdropProps>> = React.memo(
 
     return (
       <CSSTransition name="backdrop-wrapper" visible={visible} clearTime={300}>
-        <div className="backdrop" onClick={clickHandler} onMouseUp={mouseUpHandler}>
+        <div className="backdrop" onClick={clickHandler} onMouseUp={mouseUpHandler} {...props}>
           <div className="layer" />
           <div
             onClick={childrenClickHandler}
diff --git a/components/utils/collections.ts b/components/utils/collections.ts
index a6cb23e..149d502 100644
--- a/components/utils/collections.ts
+++ b/components/utils/collections.ts
@@ -111,3 +111,16 @@ export const getReactNode = (node?: React.ReactNode | (() => React.ReactNode)):
   if (typeof node !== 'function') return node
   return (node as () => React.ReactNode)()
 }
+
+export const isChildElement = (
+  parent: Element | null | undefined,
+  child: Element | null | undefined,
+): boolean => {
+  if (!parent || !child) return false
+  let node: (Node & ParentNode) | null = child
+  while (node) {
+    if (node === parent) return true
+    node = node.parentNode
+  }
+  return false
+}


Test patch:
diff --git a/components/button-group/__tests__/__snapshots__/index.test.tsx.snap b/components/button-group/__tests__/__snapshots__/index.test.tsx.snap
index 06fd2bb..130961b 100644
--- a/components/button-group/__tests__/__snapshots__/index.test.tsx.snap
+++ b/components/button-group/__tests__/__snapshots__/index.test.tsx.snap
@@ -35,7 +35,8 @@ exports[`ButtonGroup buttons should be displayed vertically 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -96,7 +97,8 @@ exports[`ButtonGroup buttons should be displayed vertically 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -205,7 +207,8 @@ exports[`ButtonGroup props should be passed to each button 1`] = `
             --zeit-ui-button-bg: #0070f3;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #0070f3;
             --zeit-ui-button-color: #0070f3;
             background-color: #fff;
@@ -314,7 +317,8 @@ exports[`ButtonGroup props should be passed to each button 2`] = `
             --zeit-ui-button-bg: #0070f3;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #0070f3;
             --zeit-ui-button-color: #0070f3;
             background-color: #fff;
@@ -423,7 +427,8 @@ exports[`ButtonGroup should render correctly 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
diff --git a/components/button/__tests__/__snapshots__/icon.test.tsx.snap b/components/button/__tests__/__snapshots__/icon.test.tsx.snap
index 6653687..b0e470b 100644
--- a/components/button/__tests__/__snapshots__/icon.test.tsx.snap
+++ b/components/button/__tests__/__snapshots__/icon.test.tsx.snap
@@ -71,7 +71,8 @@ exports[`ButtonIcon should render correctly 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -172,7 +173,8 @@ exports[`ButtonIcon should work with right 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -266,7 +268,8 @@ exports[`ButtonIcon should work without text 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
diff --git a/components/modal/__tests__/__snapshots__/index.test.tsx.snap b/components/modal/__tests__/__snapshots__/index.test.tsx.snap
index 382ac85..9fe2600 100644
--- a/components/modal/__tests__/__snapshots__/index.test.tsx.snap
+++ b/components/modal/__tests__/__snapshots__/index.test.tsx.snap
@@ -1,7 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Modal customization should be supported 1`] = `
-"<div class=\\"wrapper test-class  wrapper-enter\\"><h2 class=\\"\\">Modal</h2><style>
+"<div class=\\"wrapper test-class  wrapper-enter\\" role=\\"dialog\\" tabindex=\\"-1\\"><div tabindex=\\"0\\" class=\\"hide-tab\\" aria-hidden=\\"true\\"></div><h2 class=\\"\\">Modal</h2><style>
         h2 {
           font-size: 1.5rem;
           line-height: 1.6;
@@ -16,7 +16,7 @@ exports[`Modal customization should be supported 1`] = `
           text-transform: capitalize;
           color: #000;
         }
-      </style><style>
+      </style><div tabindex=\\"0\\" class=\\"hide-tab\\" aria-hidden=\\"true\\"></div><style>
           .wrapper {
             max-width: 90vw;
             max-height: 90vh;
@@ -32,6 +32,7 @@ exports[`Modal customization should be supported 1`] = `
             padding: 16pt;
             box-shadow: 0 30px 60px rgba(0, 0, 0, 0.12);
             opacity: 0;
+            outline: none;
             transform: translate3d(0px, -30px, 0px);
             transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s,
               transform 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s;
@@ -56,11 +57,19 @@ exports[`Modal customization should be supported 1`] = `
             opacity: 0;
             transform: translate3d(0px, -30px, 0px);
           }
+
+          .hide-tab {
+            outline: none;
+            overflow: hidden;
+            width: 0;
+            height: 0;
+            opacity: 0;
+          }
         </style></div>"
 `;
 
 exports[`Modal should render correctly 1`] = `
-"<div class=\\"backdrop  backdrop-wrapper-enter\\"><div class=\\"layer\\"></div><div class=\\"content\\"><div class=\\"wrapper   wrapper-enter\\"><h2 class=\\"\\">Modal</h2><style>
+"<div class=\\"backdrop  backdrop-wrapper-enter\\"><div class=\\"layer\\"></div><div class=\\"content\\"><div class=\\"wrapper   wrapper-enter\\" role=\\"dialog\\" tabindex=\\"-1\\"><div tabindex=\\"0\\" class=\\"hide-tab\\" aria-hidden=\\"true\\"></div><h2 class=\\"\\">Modal</h2><style>
         h2 {
           font-size: 1.5rem;
           line-height: 1.6;
@@ -138,7 +147,8 @@ exports[`Modal should render correctly 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -199,7 +209,8 @@ exports[`Modal should render correctly 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -249,7 +260,7 @@ exports[`Modal should render correctly 1`] = `
           height: 3.625rem;
           flex-shrink: 0;
         }
-      </style><style>
+      </style><div tabindex=\\"0\\" class=\\"hide-tab\\" aria-hidden=\\"true\\"></div><style>
           .wrapper {
             max-width: 90vw;
             max-height: 90vh;
@@ -265,6 +276,7 @@ exports[`Modal should render correctly 1`] = `
             padding: 16pt;
             box-shadow: 0 30px 60px rgba(0, 0, 0, 0.12);
             opacity: 0;
+            outline: none;
             transform: translate3d(0px, -30px, 0px);
             transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s,
               transform 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s;
@@ -289,6 +301,14 @@ exports[`Modal should render correctly 1`] = `
             opacity: 0;
             transform: translate3d(0px, -30px, 0px);
           }
+
+          .hide-tab {
+            outline: none;
+            overflow: hidden;
+            width: 0;
+            height: 0;
+            opacity: 0;
+          }
         </style></div></div><div class=\\"offset\\"></div><style>
             .backdrop {
               position: fixed;
diff --git a/components/modal/__tests__/index.test.tsx b/components/modal/__tests__/index.test.tsx
index d73d135..7e6d2c3 100644
--- a/components/modal/__tests__/index.test.tsx
+++ b/components/modal/__tests__/index.test.tsx
@@ -3,6 +3,13 @@ import { mount } from 'enzyme'
 import { Modal } from 'components'
 import { nativeEvent, updateWrapper } from 'tests/utils'
 import { expectModalIsClosed, expectModalIsOpened } from './use-modal.test'
+import { act } from 'react-dom/test-utils'
+
+const TabEvent = {
+  key: 'TAB',
+  keyCode: 9,
+  which: 9,
+}
 
 describe('Modal', () => {
   it('should render correctly', () => {
@@ -117,4 +124,32 @@ describe('Modal', () => {
     expect(html).toContain('test-class')
     expect(() => wrapper.unmount()).not.toThrow()
   })
+
+  it('focus should only be switched within modal', () => {
+    const wrapper = mount(
+      <Modal open={true} width="100px" wrapClassName="test-class">
+        <Modal.Title>Modal</Modal.Title>
+      </Modal>,
+    )
+    const tabStart = wrapper.find('.hide-tab').at(0).getDOMNode()
+    const tabEnd = wrapper.find('.hide-tab').at(1).getDOMNode()
+    const eventElement = wrapper.find('.wrapper').at(0)
+    expect(document.activeElement).toBe(tabStart)
+
+    act(() => {
+      eventElement.simulate('keydown', {
+        ...TabEvent,
+        shiftKey: true,
+      })
+    })
+    expect(document.activeElement).toBe(tabEnd)
+
+    act(() => {
+      eventElement.simulate('keydown', {
+        ...TabEvent,
+        shiftKey: false,
+      })
+    })
+    expect(document.activeElement).toBe(tabStart)
+  })
 })
diff --git a/components/use-toasts/__tests__/__snapshots__/index.test.tsx.snap b/components/use-toasts/__tests__/__snapshots__/index.test.tsx.snap
index 03acd43..7f82aa6 100644
--- a/components/use-toasts/__tests__/__snapshots__/index.test.tsx.snap
+++ b/components/use-toasts/__tests__/__snapshots__/index.test.tsx.snap
@@ -35,7 +35,8 @@ exports[`UseToast should render different actions 1`] = `
             --zeit-ui-button-bg: #000;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;
@@ -96,7 +97,8 @@ exports[`UseToast should render different actions 1`] = `
             --zeit-ui-button-bg: #fff;
           }
 
-          .btn:hover {
+          .btn:hover,
+          .btn:focus {
             color: #000;
             --zeit-ui-button-color: #000;
             background-color: #fff;