diff --git a/src/internal/focus-visible/__tests__/iframes.test.tsx b/src/internal/focus-visible/__tests__/iframes.test.tsx new file mode 100644 index 0000000..038b945 --- /dev/null +++ b/src/internal/focus-visible/__tests__/iframes.test.tsx @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { render, fireEvent } from '@testing-library/react'; +import { useFocusVisible } from '..'; + +function Fixture() { + const ref = useRef(null); + useFocusVisible(ref); + return ; +} + +function FramePortal({ children }: { children: React.ReactNode }) { + const [iframeElement, setIframeElement] = useState(null); + + return ( + + ); +} + +test('should disable focus by default', () => { + render( + + + + ); + const frame = window.frames[0]!; + expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible'); +}); + +test('should enable focus when keyboard interaction happened in the main document', () => { + render( + <> + {/* Component in the main document to make sure listeners are added here too */} + + + + + + ); + const frame = window.frames[0]!; + fireEvent.keyDown(document.body); + expect(document.body).toHaveAttribute('data-awsui-focus-visible', 'true'); + expect(frame.document.body).toHaveAttribute('data-awsui-focus-visible', 'true'); +}); + +test('should enable focus when keyboard interaction happened in the frame document', () => { + render( + <> + {/* Component in the main document to make sure listeners are added here too */} + + + + + + ); + const frame = window.frames[0]!; + fireEvent.keyDown(frame.document.body); + expect(document.body).toHaveAttribute('data-awsui-focus-visible', 'true'); + expect(frame.document.body).toHaveAttribute('data-awsui-focus-visible', 'true'); +}); + +test('should disable focus when mouse is used in the main document after keyboard', () => { + render( + <> + {/* Component in the main document to make sure listeners are added here too */} + + + + + + ); + const frame = window.frames[0]!; + fireEvent.keyDown(document.body); + fireEvent.mouseDown(document.body); + expect(document.body).not.toHaveAttribute('data-awsui-focus-visible'); + expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible'); +}); + +test('should disable focus when mouse is used in the frame document after keyboard', () => { + render( + + + + ); + const frame = window.frames[0]!; + fireEvent.keyDown(frame.document.body); + fireEvent.mouseDown(frame.document.body); + expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible'); +}); + +test('should remove listeners only from frame when the frame is unmounted', () => { + const outerAddEventListenerSpy = jest.spyOn(document, 'addEventListener'); + const { rerender } = render( + <> + + + + + + ); + + const frame = window.frames[0]!; + const innerAddEventListenerSpy = jest.spyOn(frame.document, 'addEventListener'); + rerender( + <> + + + + + + ); + + expect(outerAddEventListenerSpy).toHaveBeenCalledTimes(2); + const outerSignal = (outerAddEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!; + expect(outerSignal.aborted).toBe(false); + + expect(innerAddEventListenerSpy).toHaveBeenCalledTimes(2); + const innerSignal = (innerAddEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!; + expect(innerSignal.aborted).toBe(false); + + rerender( + <> + + + + + + ); + expect(innerSignal.aborted).toBe(true); + expect(outerSignal.aborted).toBe(false); +}); diff --git a/src/internal/focus-visible/__tests__/index.test.tsx b/src/internal/focus-visible/__tests__/index.test.tsx index ab18b90..2e3364f 100644 --- a/src/internal/focus-visible/__tests__/index.test.tsx +++ b/src/internal/focus-visible/__tests__/index.test.tsx @@ -59,20 +59,26 @@ test('should work with multiple components', () => { }); test('should add listeners only once', () => { - jest.spyOn(document, 'addEventListener'); - jest.spyOn(document, 'removeEventListener'); + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); const { rerender } = render( <> ); - expect(document.addEventListener).toHaveBeenCalledTimes(2); - expect(document.removeEventListener).toHaveBeenCalledTimes(0); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); + const signal = (addEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!; + expect(signal.aborted).toBe(false); + + addEventListenerSpy.mockClear(); rerender(); - expect(document.removeEventListener).toHaveBeenCalledTimes(0); + expect(addEventListenerSpy).toHaveBeenCalledTimes(0); + expect(signal.aborted).toBe(false); + rerender(); - expect(document.removeEventListener).toHaveBeenCalledTimes(2); + expect(addEventListenerSpy).toHaveBeenCalledTimes(0); + expect(signal.aborted).toBe(true); }); test('should initialize late components with updated state', () => { diff --git a/src/internal/focus-visible/index.ts b/src/internal/focus-visible/index.ts index 073b2aa..173d393 100644 --- a/src/internal/focus-visible/index.ts +++ b/src/internal/focus-visible/index.ts @@ -1,19 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { isModifierKey } from '../keycode'; +const frames = new Map(); + function setIsKeyboard(active: boolean) { if (active) { - document.body.setAttribute('data-awsui-focus-visible', 'true'); + for (const currentDocument of frames.keys()) { + currentDocument.body.setAttribute('data-awsui-focus-visible', 'true'); + } } else { - document.body.removeAttribute('data-awsui-focus-visible'); + for (const currentDocument of frames.keys()) { + currentDocument.body.removeAttribute('data-awsui-focus-visible'); + } } } function handleMousedown() { - return setIsKeyboard(false); + setIsKeyboard(false); } function handleKeydown(event: KeyboardEvent) { @@ -22,29 +28,32 @@ function handleKeydown(event: KeyboardEvent) { } } -let componentsCount = 0; - -function addListeners() { - document.addEventListener('mousedown', handleMousedown); - document.addEventListener('keydown', handleKeydown); +function addListeners(currentDocument: Document): AbortController { + const abortController = new AbortController(); + currentDocument.addEventListener('mousedown', handleMousedown, { signal: abortController.signal }); + currentDocument.addEventListener('keydown', handleKeydown, { signal: abortController.signal }); + return abortController; } -function removeListeners() { - document.removeEventListener('mousedown', handleMousedown); - document.removeEventListener('keydown', handleKeydown); -} - -export function useFocusVisible() { +export function useFocusVisible(componentRef?: React.RefObject) { useEffect(() => { - if (componentsCount === 0) { - addListeners(); + const currentDocument = componentRef?.current?.ownerDocument ?? document; + + let frame = frames.get(currentDocument); + if (frame) { + frame.componentsCount++; + } else { + const abortController = addListeners(currentDocument); + frame = { componentsCount: 1, abortController }; + frames.set(currentDocument, frame); } - componentsCount++; + return () => { - componentsCount--; - if (componentsCount === 0) { - removeListeners(); + frame.componentsCount--; + if (frame.componentsCount === 0) { + frame.abortController.abort(); + frames.delete(currentDocument); } }; - }, []); + }, [componentRef]); }