diff --git a/package-lock.json b/package-lock.json index 46d373ab41..4288251441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54182,7 +54182,10 @@ "@instructure/console": "8.46.1", "@instructure/ui-babel-preset": "8.46.1", "@instructure/ui-prop-types": "8.46.1", - "@instructure/ui-test-utils": "8.46.1" + "@instructure/ui-test-utils": "8.46.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3" }, "peerDependencies": { "react": ">=16.8 <=18" @@ -59851,6 +59854,9 @@ "@instructure/ui-react-utils": "8.46.1", "@instructure/ui-test-utils": "8.46.1", "@instructure/ui-testable": "8.46.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", "prop-types": "^15.8.1" } }, diff --git a/packages/ui-dialog/package.json b/packages/ui-dialog/package.json index 168e0e85ec..4af35dd36c 100644 --- a/packages/ui-dialog/package.json +++ b/packages/ui-dialog/package.json @@ -35,7 +35,10 @@ "@instructure/console": "8.46.1", "@instructure/ui-babel-preset": "8.46.1", "@instructure/ui-prop-types": "8.46.1", - "@instructure/ui-test-utils": "8.46.1" + "@instructure/ui-test-utils": "8.46.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3" }, "peerDependencies": { "react": ">=16.8 <=18" diff --git a/packages/ui-dialog/src/Dialog/__new-tests__/Dialog.test.tsx b/packages/ui-dialog/src/Dialog/__new-tests__/Dialog.test.tsx new file mode 100644 index 0000000000..e808da3b02 --- /dev/null +++ b/packages/ui-dialog/src/Dialog/__new-tests__/Dialog.test.tsx @@ -0,0 +1,557 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom/extend-expect' + +import { Dialog } from '../index' +import type { DialogProps } from '../props' + +const TEST_TEXT = 'test-text' +const TEST_LABEL = 'test-label' + +const renderDialog = (props?: Partial) => { + const allProps: DialogProps = { + open: true, + label: TEST_LABEL, + ...props + } + + return render( + + + + ) +} + +const DialogExample = forwardRef((props: DialogProps, ref: React.Ref) => { + const inputRef = useRef(null) + const dialogRef = useRef(null) + + useEffect(() => { + if (!props.open) { + inputRef.current!.focus() + } + }, [props.open]) + + useImperativeHandle(ref, () => ({ + focusDialog: () => dialogRef.current!.focus(), + blurDialog: () => dialogRef.current!.blur() + })) + + return ( +
+ + + {props.children || ( +
+ + +
+ )} +
+
+ ) +}) +DialogExample.displayName = 'DialogExample' + +const NestedDialogExample = (props: DialogProps) => { + const [nestedOpen, setNestedOpen] = useState(false) + const handleTriggerClick = () => setNestedOpen(true) + + return ( +
+ +
+
+ + +
+ + {TEST_TEXT} + +
+
+
+ ) +} +NestedDialogExample.displayName = 'NestedDialogExample' + +describe('', () => { + it('should render nothing when closed', () => { + const { container } = renderDialog({ open: false }) + + expect(container.firstChild).not.toBeInTheDocument() + }) + + it('should render children when open', async () => { + const { container } = renderDialog({ open: true }) + + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent(TEST_TEXT) + }) + + it('should apply the a11y attributes', () => { + const { getByRole, getByLabelText } = renderDialog({ label: TEST_LABEL }) + const dialog = getByRole('dialog') + const label = getByLabelText(TEST_LABEL) + + expect(dialog).toBeInTheDocument() + expect(label).toBeInTheDocument() + }) + + it('should apply the role attributes, if explicitly passed', () => { + const { getByRole, getByLabelText } = renderDialog({ + label: TEST_LABEL, + role: 'region' + }) + const regionRole = getByRole('region') + const label = getByLabelText(TEST_LABEL) + + expect(regionRole).toBeInTheDocument() + expect(label).toBeInTheDocument() + }) + + it('should call onDismiss prop when Esc key pressed', async () => { + const onDismiss = jest.fn() + const { getByRole } = renderDialog({ onDismiss }) + const dialog = getByRole('dialog') + + await waitFor(() => { + fireEvent.keyUp(dialog, { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27 + }) + expect(onDismiss).toHaveBeenCalled() + }) + }) + + it('should call onDismiss prop when the document is clicked', async () => { + const onDismiss = jest.fn() + renderDialog({ onDismiss, shouldCloseOnDocumentClick: true }) + + await waitFor(() => { + fireEvent.click(document) + expect(onDismiss).toHaveBeenCalled() + }) + }) + + describe('managed focus', () => { + it('should provide focus method', async () => { + const { getByTestId } = render( +
+ getByTestId('non-tabbable')} + > + {TEST_TEXT} + +
+ {TEST_TEXT} +
+
+ ) + const nonTabbableContent = getByTestId('non-tabbable') + + await waitFor(() => { + userEvent.tab() + expect(document.activeElement).toBe(nonTabbableContent) + }) + }) + + it('should warn when trying to focus or blur a closed dialog', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + let ref + render( (ref = el)} />) + + ref!.focusDialog() + expect(consoleError.mock.calls[0][0]).toBe( + "Warning: [Dialog] Can't focus a Dialog that isn't open." + ) + + ref!.blurDialog() + expect(consoleError.mock.calls[1][0]).toBe( + "Warning: [Dialog] Can't blur a Dialog that isn't open." + ) + + consoleError.mockRestore() + }) + + it('should focus the first tabbable element by default', async () => { + const { getByTestId } = render() + const inputOne = getByTestId('input-one') + + await waitFor(() => { + expect(document.activeElement).toBe(inputOne) + }) + }) + + it('should focus the first tabbable element when open prop becomes true', async () => { + const { rerender, getByTestId } = render() + const inputTrigger = getByTestId('input-trigger') + + await waitFor(() => { + expect(document.activeElement).toBe(inputTrigger) + }) + + rerender() + const inputOne = getByTestId('input-one') + + await waitFor(() => { + expect(document.activeElement).toBe(inputOne) + }) + }) + + it('should take a prop for finding default focus', async () => { + const { getByTestId } = render( + getByTestId('input-two')} + /> + ) + const inputTwo = getByTestId('input-two') + + await waitFor(() => { + expect(document.activeElement).toBe(inputTwo) + }) + }) + + it('should still focus the defaultFocusElement when it is focusable but not tabbable', async () => { + const { getByTestId } = render( + getByTestId('non-tabbable')} + > +
+ {TEST_TEXT} +
+
+ ) + const nonTabbableContent = getByTestId('non-tabbable') + + await waitFor(() => { + expect(document.activeElement).toBe(nonTabbableContent) + }) + }) + + it('should focus the contentElement by default if focusable and no defaultFocusElement is provided', async () => { + const { getByTestId } = render( +
+ getByTestId('non-tabbable')} + > + {TEST_TEXT} + +
+ {TEST_TEXT} +
+
+ ) + const nonTabbableContent = getByTestId('non-tabbable') + + await waitFor(() => { + expect(document.activeElement).toBe(nonTabbableContent) + }) + }) + + it('should focus the document body if there is no defaultFocusElement, tabbable elements, or focusable contentElement', async () => { + const { rerender, getByTestId } = render( + {TEST_TEXT} + ) + const inputTrigger = getByTestId('input-trigger') + inputTrigger.focus() + + rerender({TEST_TEXT}) + + await waitFor(() => { + expect(document.activeElement).toBe(document.body) + }) + }) + + it('should return focus', async () => { + const { rerender, getByTestId } = render() + expect(document.activeElement).toBe(getByTestId('input-trigger')) + + rerender() + await waitFor(() => { + expect(document.activeElement).toBe(getByTestId('input-one')) + }) + + rerender() + await waitFor(() => { + expect(document.activeElement).toBe(getByTestId('input-trigger')) + }) + }) + + describe('when focus leaves the first and last tabbable', () => { + it(`should NOT call onBlur when shouldContainFocus=true and tab pressing last tabbable`, async () => { + const onBlur = jest.fn() + const { getByTestId } = render( + getByTestId('input-two')} + /> + ) + const inputOne = getByTestId('input-one') + const inputTwo = getByTestId('input-two') + + await waitFor(() => { + expect(document.activeElement).toBe(inputTwo) + }) + + await waitFor(() => { + userEvent.tab() + expect(onBlur).not.toHaveBeenCalled() + expect(document.activeElement).toBe(inputOne) + }) + }) + + it('should NOT call onBlur when shouldContainFocus=true and Shift+Tab pressing first tabbable', async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('input-one')} + onBlur={onBlur} + /> + ) + const inputOne = getByTestId('input-one') + const inputTwo = getByTestId('input-two') + + await waitFor(() => { + expect(document.activeElement).toBe(inputOne) + + fireEvent.keyDown(inputOne, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9, + shiftKey: true + }) + + expect(onBlur).not.toHaveBeenCalled() + expect(document.activeElement).toBe(inputTwo) + }) + }) + + it('should call onBlur when shouldContainFocus=false and tab pressing last tabbable', async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('input-two')} + onBlur={onBlur} + /> + ) + const inputTwo = getByTestId('input-two') + inputTwo.focus() + + await waitFor(() => { + expect(document.activeElement).toBe(inputTwo) + + fireEvent.keyDown(inputTwo, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9 + }) + expect(onBlur).toHaveBeenCalled() + }) + }) + + it('should call onBlur when shouldContainFocus=false and pressing Shift+Tab on the first tabbable', async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('input-one')} + onBlur={onBlur} + /> + ) + const inputOne = getByTestId('input-one') + inputOne.focus() + + await waitFor(() => { + expect(document.activeElement).toBe(inputOne) + + fireEvent.keyDown(inputOne, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9, + shiftKey: true + }) + expect(onBlur).toHaveBeenCalled() + }) + }) + + describe('when launching a dialog w/out focusable content from another dialog', () => { + it(`should contain focus when last tabbable element triggers dialog w/out focusable content`, async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('nested-input-two')} + /> + ) + const inputOne = getByTestId('nested-input-one') + const inputTwo = getByTestId('nested-input-two') + + await waitFor(() => { + userEvent.click(inputTwo) + expect(document.activeElement).toBe(inputTwo) + + fireEvent.keyDown(inputTwo, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9 + }) + expect(onBlur).not.toHaveBeenCalled() + expect(document.activeElement).toBe(inputOne) + }) + }) + + it('should contain focus when first tabbable element triggers dialog w/out focusable content', async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('nested-input-one')} + /> + ) + const inputOne = getByTestId('nested-input-one') + const inputTwo = getByTestId('nested-input-two') + + await waitFor(() => { + userEvent.click(inputOne) + expect(document.activeElement).toBe(inputOne) + + fireEvent.keyDown(inputOne, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9, + shiftKey: true + }) + expect(onBlur).not.toHaveBeenCalled() + expect(document.activeElement).toBe(inputTwo) + }) + }) + + it(`should call onBlur when shouldContainFocus=false and last tabbable element triggers dialog w/out focusable content`, async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('nested-input-two')} + /> + ) + const inputTwo = getByTestId('nested-input-two') + + await waitFor(() => { + userEvent.click(inputTwo) + expect(document.activeElement).toBe(inputTwo) + + fireEvent.keyDown(inputTwo, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9 + }) + expect(onBlur).toHaveBeenCalled() + }) + }) + + it(`should call onBlur when shouldContainFocus=false and first tabbable element triggers dialog w/out focusable content`, async () => { + const onBlur = jest.fn() + + const { getByTestId } = render( + getByTestId('nested-input-one')} + /> + ) + const inputOne = getByTestId('nested-input-one') + + await waitFor(() => { + userEvent.click(inputOne) + expect(document.activeElement).toBe(inputOne) + + fireEvent.keyDown(inputOne, { + key: 'Tab', + code: 'Tab', + keyCode: 9, + charCode: 9, + shiftKey: true + }) + expect(onBlur).toHaveBeenCalled() + }) + }) + }) + }) + }) +})