Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(modal): lock tab action inside modal #354

Merged
merged 5 commits into from
Aug 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions components/button/__tests__/__snapshots__/icon.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
32 changes: 26 additions & 6 deletions components/modal/__tests__/__snapshots__/index.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions components/modal/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
3 changes: 2 additions & 1 deletion components/modal/modal-action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
Expand Down
48 changes: 46 additions & 2 deletions components/modal/modal-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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>
Expand Down
8 changes: 4 additions & 4 deletions components/shared/backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>) => {
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions components/utils/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}