diff --git a/packages/store-ui/package.json b/packages/store-ui/package.json index b07e754fe2..10d5d1dfc8 100644 --- a/packages/store-ui/package.json +++ b/packages/store-ui/package.json @@ -52,7 +52,8 @@ ], "dependencies": { "@reach/popover": "^0.16.0", - "react-swipeable": "^6.1.2" + "react-swipeable": "^6.1.2", + "tabbable": "^5.2.1" }, "peerDependencies": { "react": "^17.0.2", @@ -70,9 +71,12 @@ "@testing-library/jest-dom": "^5.12.0", "@testing-library/react": "^11.2.6", "@testing-library/user-event": "^13.1.8", + "@types/jest-axe": "^3.5.3", + "@types/tabbable": "^3.1.1", "@types/testing-library__jest-dom": "^5.9.5", "@vtex/theme-b2c-tailwind": "^1.1.11", "@vtex/tsconfig": "^0.5.0", + "jest-axe": "^5.0.1", "react": "^17.0.2", "react-docgen-typescript-loader": "^3.7.2", "react-dom": "^17.0.2", diff --git a/packages/store-ui/src/index.ts b/packages/store-ui/src/index.ts index 6a75f5138f..73a0f0d613 100644 --- a/packages/store-ui/src/index.ts +++ b/packages/store-ui/src/index.ts @@ -57,6 +57,9 @@ export type { CarouselProps } from './molecules/Carousel' export { default as IconButton } from './molecules/IconButton' export type { IconButtonProps } from './molecules/IconButton' +export { default as Modal } from './molecules/Modal' +export type { ModalProps } from './molecules/Modal' + // Hooks export { default as useSlider } from './hooks/useSlider' export type { diff --git a/packages/store-ui/src/molecules/Modal/Modal.test.tsx b/packages/store-ui/src/molecules/Modal/Modal.test.tsx new file mode 100644 index 0000000000..907ed87b96 --- /dev/null +++ b/packages/store-ui/src/molecules/Modal/Modal.test.tsx @@ -0,0 +1,233 @@ +import type { ReactNode } from 'react' +import React, { useState } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { axe } from 'jest-axe' + +import Modal from './Modal' +import Button from '../../atoms/Button' +import Input from '../../atoms/Input' + +const modalTestId = 'store-modal' + +const TestModal = ({ + children, + onDismiss: mockOnDismiss, +}: { + children?: ReactNode + onDismiss?: () => void +}) => { + const [isOpen, setIsOpen] = useState(false) + const handleOpen = () => { + setIsOpen(true) + } + + const onDismiss = () => { + setIsOpen(false) + mockOnDismiss?.() + } + + return ( + <> + + + + + + My Modal Content +
+
+ +
+
+ + ) +} + +export const Modal = ModalTemplate.bind({}) + +export default { + title: 'Molecules/Modal', + parameters: { + docs: { + page: mdx, + }, + }, +} as Meta diff --git a/packages/store-ui/src/molecules/Modal/useTrapFocus.ts b/packages/store-ui/src/molecules/Modal/useTrapFocus.ts new file mode 100644 index 0000000000..f9759f384d --- /dev/null +++ b/packages/store-ui/src/molecules/Modal/useTrapFocus.ts @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import type { FocusableElement } from 'tabbable' +import { tabbable } from 'tabbable' + +interface TrapFocusParams { + beforeElementRef: RefObject + trapFocusRef: RefObject + afterElementRef: RefObject +} + +/* + * Element that will maintain the focus inside trapFocusRef, focus the first element, + * and focus back on the element that was in focus when useTrapFocus was triggered. + * + * Inspired by Reakit useTrapFocus https://github.com/reakit/reakit/blob/a211d94da9f3b683182568a56479b91afb1b85ae/packages/reakit/src/Dialog/__utils/useFocusTrap.ts + */ +const useTrapFocus = ({ + trapFocusRef, + beforeElementRef, + afterElementRef, +}: TrapFocusParams) => { + const tabbableNodesRef = useRef() + const nodeToRestoreRef = useRef( + document.hasFocus() ? (document.activeElement as HTMLElement) : null + ) + + // Focus back on the element that was focused when useTrapFocus is triggered. + useEffect(() => { + const nodeToRestore = nodeToRestoreRef.current + + return () => { + nodeToRestore?.focus() + } + }, [nodeToRestoreRef]) + + // Set focus on first tabbable element + useEffect(() => { + if (!trapFocusRef.current) { + return + } + + if (!tabbableNodesRef.current) { + tabbableNodesRef.current = tabbable(trapFocusRef.current) + } + + const [firstTabbable] = tabbableNodesRef.current + + if (!firstTabbable) { + trapFocusRef.current.focus() + + return + } + + firstTabbable.focus() + }, [trapFocusRef]) + + // Handle loop focus. Set keydown and focusin event listeners + useEffect(() => { + if ( + !trapFocusRef.current || + !beforeElementRef.current || + !afterElementRef.current + ) { + return + } + + const beforeElement = beforeElementRef.current + const afterElement = afterElementRef.current + const trapFocus = trapFocusRef.current + + const handleLoopFocus = (nativeEvent: FocusEvent) => { + if (!document.hasFocus()) { + return + } + + tabbableNodesRef.current = tabbable(trapFocusRef.current!) + + if (!tabbableNodesRef.current.length) { + trapFocus.focus() + } + + /* + * Handle loop focus from beforeElementRef. This node can only be focused if the user press shift tab. + * It will focus the last element of the trapFocusRef. + */ + if (nativeEvent.target === beforeElement) { + tabbableNodesRef.current[tabbableNodesRef.current.length - 1]?.focus() + } + + /* + * Handle loop focus from afterElementRef. This node can only be focused if the user press tab. + * It will focus the first element of the trapFocusRef. + */ + if (nativeEvent.target === afterElement) { + tabbableNodesRef.current[0]?.focus() + } + } + + beforeElement?.addEventListener('focusin', handleLoopFocus) + afterElement?.addEventListener('focusin', handleLoopFocus) + + return () => { + beforeElement?.removeEventListener('focusin', handleLoopFocus) + afterElement?.removeEventListener('focusin', handleLoopFocus) + } + }, [tabbableNodesRef, afterElementRef, beforeElementRef, trapFocusRef]) +} + +export default useTrapFocus diff --git a/packages/store-ui/src/setupTests.ts b/packages/store-ui/src/setupTests.ts index 264828a905..eceeb9a036 100644 --- a/packages/store-ui/src/setupTests.ts +++ b/packages/store-ui/src/setupTests.ts @@ -1 +1,4 @@ import '@testing-library/jest-dom/extend-expect' +import { toHaveNoViolations } from 'jest-axe' + +expect.extend(toHaveNoViolations) diff --git a/themes/theme-b2c-tailwind/src/atoms/overlay.css b/themes/theme-b2c-tailwind/src/atoms/overlay.css index 34e5bb963e..749f4ada88 100644 --- a/themes/theme-b2c-tailwind/src/atoms/overlay.css +++ b/themes/theme-b2c-tailwind/src/atoms/overlay.css @@ -11,6 +11,6 @@ @apply bg-green-500 bg-opacity-50; } -[data-store-overlay][data-black] { +[data-store-overlay][data-black], [data-modal-overlay] { @apply bg-gray-500 bg-opacity-50; } diff --git a/themes/theme-b2c-tailwind/src/molecules/index.css b/themes/theme-b2c-tailwind/src/molecules/index.css index 200fb0f606..4a0cf3dd5d 100644 --- a/themes/theme-b2c-tailwind/src/molecules/index.css +++ b/themes/theme-b2c-tailwind/src/molecules/index.css @@ -3,3 +3,4 @@ @import "./icon-button.css"; @import './price-range.css'; @import "./carousel.css"; +@import "./modal.css"; diff --git a/themes/theme-b2c-tailwind/src/molecules/modal.css b/themes/theme-b2c-tailwind/src/molecules/modal.css new file mode 100644 index 0000000000..08ad79015d --- /dev/null +++ b/themes/theme-b2c-tailwind/src/molecules/modal.css @@ -0,0 +1,9 @@ +[data-store-modal-content] { + @apply bg-white w-96 font-sans; + @apply p-5 rounded; +} + +[data-store-modal-content] > [data-action-container] { + @apply flex justify-end; +} + diff --git a/yarn.lock b/yarn.lock index 60e7b041f4..43be6018eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6062,6 +6062,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest-axe@^3.5.3": + version "3.5.3" + resolved "https://registry.yarnpkg.com/@types/jest-axe/-/jest-axe-3.5.3.tgz#5af918553388aa0a448af75603b44093985778c6" + integrity sha512-ad9qI9f+00N8IlOuGh6dnZ6o0BDdV9VhGfTUr1zCejsPvOfZd6eohffe4JYxUoUuRYEftyMcaJ6Ux4+MsOpGHg== + dependencies: + "@types/jest" "*" + axe-core "^3.5.5" + "@types/jest@*": version "26.0.23" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.23.tgz#a1b7eab3c503b80451d019efb588ec63522ee4e7" @@ -6333,6 +6341,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/tabbable@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/tabbable/-/tabbable-3.1.1.tgz#af752e3ca8f4062fd4eccb2a8aa1394ceb5d62dc" + integrity sha512-W7XRO21VJ3Mh8YQWsjqfp6ooBNn5v+Njs1IahnElCv2g8zufYDQMxIS2seXbpmK/2HCdBQItW+xIimcPe1q+5g== + "@types/tapable@^1", "@types/tapable@^1.0.5": version "1.0.7" resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.7.tgz#545158342f949e8fd3bfd813224971ecddc3fac4" @@ -7187,6 +7200,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-to-html@^0.6.11: version "0.6.14" resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.14.tgz#65fe6d08bba5dd9db33f44a20aec331e0010dad8" @@ -7587,6 +7605,16 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== +axe-core@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.2.1.tgz#2e50bcf10ee5b819014f6e342e41e45096239e34" + integrity sha512-evY7DN8qSIbsW2H/TWQ1bX3sXN1d4MNb5Vb4n7BzPuCwRHdkZ1H2eNLuSh73EoQqkGKUtju2G2HCcjCfhvZIAA== + +axe-core@^3.5.5: + version "3.5.6" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.5.6.tgz#e762a90d7f6dbd244ceacb4e72760ff8aad521b5" + integrity sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ== + axe-core@^4.0.2: version "4.1.3" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.3.tgz#64a4c85509e0991f5168340edc4bedd1ceea6966" @@ -8770,6 +8798,14 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.1, chalk@^2.3. escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@4.1.0, chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^1.0.0, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -8789,14 +8825,6 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" - integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -10704,6 +10732,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff-sequences@^27.0.6: + version "27.0.6" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.0.6.tgz#3305cb2e55a033924054695cc66019fd7f8e5723" + integrity sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -12570,7 +12603,12 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0, follow-redirects@^1.14.0: +follow-redirects@^1.0.0: + version "1.13.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + +follow-redirects@^1.14.0: version "1.14.3" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e" integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw== @@ -15896,6 +15934,16 @@ iterate-value@^1.0.2: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +jest-axe@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/jest-axe/-/jest-axe-5.0.1.tgz#26c43643b2e5f2bd4900c1ab36f8283635957a6e" + integrity sha512-MMOWA6gT4pcZGbTLS8ZEqABH08Lnj5bInfLPpn9ADWX2wFF++odbbh8csmSfkwKjHaioVPzlCtIypAtxFDx/rw== + dependencies: + axe-core "4.2.1" + chalk "4.1.0" + jest-matcher-utils "27.0.2" + lodash.merge "4.6.2" + jest-changed-files@^25.5.0: version "25.5.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-25.5.0.tgz#141cc23567ceb3f534526f8614ba39421383634c" @@ -15970,6 +16018,16 @@ jest-diff@^26.0.0: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-diff@^27.0.2: + version "27.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.2.0.tgz#bda761c360f751bab1e7a2fe2fc2b0a35ce8518c" + integrity sha512-QSO9WC6btFYWtRJ3Hac0sRrkspf7B01mGrrQEiCW6TobtViJ9RWL0EmOs/WnBsZDsI/Y2IoSHZA2x6offu0sYw== + dependencies: + chalk "^4.0.0" + diff-sequences "^27.0.6" + jest-get-type "^27.0.6" + pretty-format "^27.2.0" + jest-docblock@^25.3.0: version "25.3.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-25.3.0.tgz#8b777a27e3477cd77a168c05290c471a575623ef" @@ -16022,6 +16080,11 @@ jest-get-type@^26.3.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== +jest-get-type@^27.0.1, jest-get-type@^27.0.6: + version "27.0.6" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.0.6.tgz#0eb5c7f755854279ce9b68a9f1a4122f69047cfe" + integrity sha512-XTkK5exIeUbbveehcSR8w0bhH+c0yloW/Wpl+9vZrjzztCPWrxhHwkIFpZzCt71oRBsgxmuUfxEqOYoZI2macg== + jest-haste-map@^25.5.1: version "25.5.1" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-25.5.1.tgz#1df10f716c1d94e60a1ebf7798c9fb3da2620943" @@ -16114,6 +16177,16 @@ jest-leak-detector@^25.5.0: jest-get-type "^25.2.6" pretty-format "^25.5.0" +jest-matcher-utils@27.0.2: + version "27.0.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.0.2.tgz#f14c060605a95a466cdc759acc546c6f4cbfc4f0" + integrity sha512-Qczi5xnTNjkhcIB0Yy75Txt+Ez51xdhOxsukN7awzq2auZQGPHcQrJ623PZj0ECDEMOk2soxWx05EXdXGd1CbA== + dependencies: + chalk "^4.0.0" + jest-diff "^27.0.2" + jest-get-type "^27.0.1" + pretty-format "^27.0.2" + jest-matcher-utils@^25.5.0: version "25.5.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-25.5.0.tgz#fbc98a12d730e5d2453d7f1ed4a4d948e34b7867" @@ -17143,7 +17216,7 @@ lodash.memoize@4.x, lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.merge@^4.6.2: +lodash.merge@4.6.2, lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== @@ -20547,6 +20620,16 @@ pretty-format@^26.0.0, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.2, pretty-format@^27.2.0: + version "27.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.2.0.tgz#ee37a94ce2a79765791a8649ae374d468c18ef19" + integrity sha512-KyJdmgBkMscLqo8A7K77omgLx5PWPiXJswtTtFV7XgVZv2+qPk6UivpXXO+5k6ZEbWIbLoKdx1pZ6ldINzbwTA== + dependencies: + "@jest/types" "^27.1.1" + ansi-regex "^5.0.0" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" @@ -23457,6 +23540,11 @@ tabbable@^4.0.0: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== +tabbable@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" + integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ== + table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"