From b0b9261c9ff884d6df647a3851fa931d19244e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Ba=CC=88chler?= Date: Mon, 12 May 2025 18:18:33 +0200 Subject: [PATCH 1/4] add shadow dom test --- cypress/e2e/useMultipleCombobox.cy.js | 41 +++++++++++ .../pages/shadow-dom/useMultipleCombobox.js | 15 ++++ docusaurus/pages/shadow-dom/useSelect.js | 10 +++ src/__tests__/downshift.shadow-root.js | 35 +++++++++ test/react-shadow.js | 73 +++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 docusaurus/pages/shadow-dom/useMultipleCombobox.js create mode 100644 docusaurus/pages/shadow-dom/useSelect.js create mode 100644 src/__tests__/downshift.shadow-root.js create mode 100644 test/react-shadow.js diff --git a/cypress/e2e/useMultipleCombobox.cy.js b/cypress/e2e/useMultipleCombobox.cy.js index 20fec955..3ca27299 100644 --- a/cypress/e2e/useMultipleCombobox.cy.js +++ b/cypress/e2e/useMultipleCombobox.cy.js @@ -6,11 +6,52 @@ describe('useMultipleCombobox', () => { it('can select multiple items', () => { cy.findByRole('button', {name: 'toggle menu'}).click() cy.findByRole('option', {name: 'Green'}).click() + cy.findByRole('button', {name: 'toggle menu'}).should( + 'have.attr', + 'aria-expanded', + 'true', + ) cy.findByRole('option', {name: 'Gray'}).click() cy.findByRole('button', {name: 'toggle menu'}).click() + cy.findByRole('button', {name: 'toggle menu'}).should( + 'have.attr', + 'aria-expanded', + 'false', + ) cy.findByText('Black').should('be.visible') cy.findByText('Red').should('be.visible') cy.findByText('Green').should('be.visible') cy.findByText('Gray').should('be.visible') }) }) + +describe('useMultipleCombobox in shadow DOM', () => { + before(() => { + cy.visit('/shadow-dom/useMultipleCombobox') + }) + + it('can select multiple items within a shadow DOM', () => { + cy.get('[data-testid="shadow-root"]') + .shadow() + .within(() => { + cy.findByRole('button', {name: 'toggle menu'}).click() + cy.findByRole('option', {name: 'Green'}).click() + cy.findByRole('button', {name: 'toggle menu'}).should( + 'have.attr', + 'aria-expanded', + 'true', + ) + cy.findByRole('option', {name: 'Gray'}).click() + cy.findByRole('button', {name: 'toggle menu'}).click() + cy.findByRole('button', {name: 'toggle menu'}).should( + 'have.attr', + 'aria-expanded', + 'false', + ) + cy.findByText('Black').should('be.visible') + cy.findByText('Red').should('be.visible') + cy.findByText('Green').should('be.visible') + cy.findByText('Gray').should('be.visible') + }) + }) +}) diff --git a/docusaurus/pages/shadow-dom/useMultipleCombobox.js b/docusaurus/pages/shadow-dom/useMultipleCombobox.js new file mode 100644 index 00000000..90098f2f --- /dev/null +++ b/docusaurus/pages/shadow-dom/useMultipleCombobox.js @@ -0,0 +1,15 @@ +import * as React from 'react' + +import DropdownMultipleCombobox from '../useMultipleCombobox' +import {ReactShadowRoot} from '../../../test/react-shadow' + +export default function MultipleComboboxShadow() { + return ( +
+ +

Shadow DOM

+ +
+
+ ) +} diff --git a/docusaurus/pages/shadow-dom/useSelect.js b/docusaurus/pages/shadow-dom/useSelect.js new file mode 100644 index 00000000..53f99dcd --- /dev/null +++ b/docusaurus/pages/shadow-dom/useSelect.js @@ -0,0 +1,10 @@ +import * as React from 'react' + +import DropdownSelect from '../useSelect' +import {ReactShadowRoot} from '../../../test/react-shadow' + +export default function DropdownSelectShadow() { + return + + +} diff --git a/src/__tests__/downshift.shadow-root.js b/src/__tests__/downshift.shadow-root.js new file mode 100644 index 00000000..9006b929 --- /dev/null +++ b/src/__tests__/downshift.shadow-root.js @@ -0,0 +1,35 @@ +import * as React from 'react' +import {render} from '@testing-library/react' +import Downshift from '../' +import DropdownSelect from '../../test/useSelect.test' +import DropdownCombobox from '../../test/useCombobox.test' +import DropdownMultipleSelect from '../../test/useMultipleSelect.test' +import {ReactShadowRoot} from '../../test/react-shadow' + +const Wrapper = ({children}) => { + return {children} +} + +test('Downshift renders with a shadow root', () => { + const {container} = render(, {wrapper: Wrapper}) + + expect(container.shadowRoot).toBeDefined() +}) + +test('DropdownSelect renders with a shadow root', () => { + const {container} = render(, {wrapper: Wrapper}) + + expect(container.shadowRoot).toBeDefined() +}) + +test('DropdownCombobox renders with a shadow root', () => { + const {container} = render(, {wrapper: Wrapper}) + + expect(container.shadowRoot).toBeDefined() +}) + +test('DropdownMultipleSelect renders with a shadow root', () => { + const {container} = render(, {wrapper: Wrapper}) + + expect(container.shadowRoot).toBeDefined() +}) diff --git a/test/react-shadow.js b/test/react-shadow.js new file mode 100644 index 00000000..482b987d --- /dev/null +++ b/test/react-shadow.js @@ -0,0 +1,73 @@ +import React, {useState, useRef, useEffect} from 'react' +import ReactDOM from 'react-dom' + +/** + * Utility to render a component with a shadow root. + * + * Based on React Shadow: https://github.com/apearce/react-shadow-root/blob/master/src/lib/ReactShadowRoot.js + */ + +const constructableStylesheetsSupported = + typeof window !== 'undefined' && + window.ShadowRoot && + window.ShadowRoot.prototype.hasOwnProperty('adoptedStyleSheets') && + window.CSSStyleSheet && + window.CSSStyleSheet.prototype.hasOwnProperty('replace') + +const shadowRootSupported = + typeof window !== 'undefined' && + window.Element && + window.Element.prototype.hasOwnProperty('attachShadow') + +/** + * @param {object} props Properties passed to the component + * @param {boolean} props.declarative When true, uses a declarative shadow root + * @param {boolean} props.delegatesFocus Expands the focus behavior of elements within the shadow DOM. + * @param {string} props.mode Sets the mode of the shadow root. (open or closed) + * @param {CSSStyleSheet[]} props.stylesheets Takes an array of CSSStyleSheet objects for constructable stylesheets. + */ +const ReactShadowRoot = ({ + declarative = false, + delegatesFocus = false, + mode = 'open', + stylesheets, + children, +}) => { + const [initialized, setInitialized] = useState(false) + const placeholder = useRef(null) + const shadowRootRef = useRef(null) + + useEffect(() => { + if (placeholder.current) { + shadowRootRef.current = placeholder.current.parentNode.attachShadow({ + delegatesFocus, + mode, + }) + + if (stylesheets) { + shadowRootRef.current.adoptedStyleSheets = stylesheets + } + + setInitialized(true) + } + }, [delegatesFocus, mode, stylesheets]) + + if (!initialized) { + if (declarative) { + // @ts-ignore + return ( + + ) + } + + return + } + + return ReactDOM.createPortal(children, shadowRootRef.current) +} + +ReactShadowRoot.displayName = 'ReactShadowRoot' + +export {ReactShadowRoot, constructableStylesheetsSupported, shadowRootSupported} From a06a26b9bd9e46449e1a3c0ec632aecce6e4dfa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Ba=CC=88chler?= Date: Tue, 13 May 2025 11:04:19 +0200 Subject: [PATCH 2/4] Patch DOM traversal --- src/__tests__/downshift.shadow-root.js | 139 ++++++++++++++++++++++++- src/downshift.js | 3 + src/hooks/utils.js | 9 +- src/utils.js | 39 ++++--- test/useCombobox.test.tsx | 2 +- 5 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/__tests__/downshift.shadow-root.js b/src/__tests__/downshift.shadow-root.js index 9006b929..8dee3383 100644 --- a/src/__tests__/downshift.shadow-root.js +++ b/src/__tests__/downshift.shadow-root.js @@ -1,11 +1,36 @@ import * as React from 'react' -import {render} from '@testing-library/react' +import {render, buildQueries, queryAllByRole} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Downshift from '../' import DropdownSelect from '../../test/useSelect.test' -import DropdownCombobox from '../../test/useCombobox.test' +import DropdownCombobox, {colors} from '../../test/useCombobox.test' import DropdownMultipleSelect from '../../test/useMultipleSelect.test' import {ReactShadowRoot} from '../../test/react-shadow' + +function _queryAllByRoleDeep(container, ...rest) { + // eslint-disable-next-line testing-library/prefer-screen-queries + const result = queryAllByRole(container, ...rest) // replace here with different queryAll* variants. + for (const element of container.querySelectorAll('*')) { + if (element.shadowRoot) { + result.push(..._queryAllByRoleDeep(element.shadowRoot, ...rest)) + } + } + + return result + } + + // eslint-disable-next-line no-unused-vars + const [_queryByRoleDeep, _getAllByRoleDeep, _getByRoleDeep, _findAllByRoleDeep, _findByRoleDeep] = buildQueries( + _queryAllByRoleDeep, + (_, role) => `Found multiple elements with the role ${role}`, + (_, role) => `Unable to find an element with the role ${role}` + ) + + const getAllByRoleDeep = _getAllByRoleDeep.bind(null, document.body) + const getByRoleDeep = _getByRoleDeep.bind(null, document.body) + + const Wrapper = ({children}) => { return {children} } @@ -33,3 +58,113 @@ test('DropdownMultipleSelect renders with a shadow root', () => { expect(container.shadowRoot).toBeDefined() }) + +test('DropdownSelect works correctly in shadow DOM', async () => { + const user = userEvent.setup() + const {container} = render(, {wrapper: Wrapper}) + + // Verify shadow root exists + expect(container.shadowRoot).toBeDefined() + + // Get elements within the shadow root + const toggleButton = getByRoleDeep('combobox') + const menu = getByRoleDeep('listbox') + expect(toggleButton).toBeInTheDocument() + expect(menu).toBeInTheDocument() + + // Initially menu should be closed + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + + // Open the dropdown + await user.click(toggleButton) + + // Menu should now be open + expect(toggleButton).toHaveAttribute('aria-expanded', 'true') + + // Select an item + const blackOption = getByRoleDeep('option', {name: 'Black'}) + await user.click(blackOption) + + // Menu should close and selected item should appear in button + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(toggleButton).toHaveTextContent('Black') + + // Open it again + await user.click(toggleButton) + expect(toggleButton).toHaveAttribute('aria-expanded', 'true') + + // Click outside (this tests our targetWithinDownshift with composedPath) + await user.click(document.body) + + // Menu should close + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') +}) + +test('DropdownCombobox works correctly in shadow DOM', async () => { + const user = userEvent.setup() + const {container} = render(, {wrapper: Wrapper}) + + // Verify shadow root exists + expect(container.shadowRoot).toBeDefined() + + // Get elements within the shadow root + const input = getByRoleDeep('combobox') + const toggleButton = getByRoleDeep('button', {name: 'toggle menu'}) + const clearButton = getByRoleDeep('button', {name: 'clear'}) + const menu = getByRoleDeep('listbox') + + expect(input).toBeInTheDocument() + expect(toggleButton).toBeInTheDocument() + expect(clearButton).toBeInTheDocument() + expect(menu).toBeInTheDocument() + + // Initially menu should be closed + expect(input).toHaveAttribute('aria-expanded', 'false') + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + + // Open the dropdown + await user.click(toggleButton) + + // Menu should now be open + expect(input).toHaveAttribute('aria-expanded', 'true') + + // All colors should initially be visible + const items = getAllByRoleDeep('option') + expect(items).toHaveLength(colors.length) + +// // Type in the input to filter items +// await user.click(input) +// input.focus() +// expect(input).toHaveFocus() +// await user.type(input, 'bl') + +// // Only Black and Blue should be visible +// items = getAllByRoleDeep('option') +// await waitFor(() => expect(items).toHaveLength(2)) +// expect(items[0]).toHaveTextContent('Black') +// expect(items[1]).toHaveTextContent('Blue') + + // Select an item + await user.click(items[0]) + + // Menu should close and input should have selected value + expect(input).toHaveAttribute('aria-expanded', 'false') + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveValue('Black') + + // Clear the selection + await user.click(clearButton) + + // Input should be empty + expect(input).toHaveValue('') + + // Open it again + await user.click(toggleButton) + expect(input).toHaveAttribute('aria-expanded', 'true') + + // Click outside to close + await user.click(document.body) + + // Menu should close + expect(input).toHaveAttribute('aria-expanded', 'false') +}) diff --git a/src/downshift.js b/src/downshift.js index 49bdda0f..c2802685 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -1093,6 +1093,8 @@ class Downshift extends Component { event.target, [this._rootNode, this._menuNode], this.props.environment, + true, + 'composedPath' in event && event.composedPath(), ) if (!contextWithinDownshift && this.getState().isOpen) { this.reset({type: stateChangeTypes.mouseUp}, () => @@ -1120,6 +1122,7 @@ class Downshift extends Component { [this._rootNode, this._menuNode], this.props.environment, false, + 'composedPath' in event && event.composedPath(), ) if ( !this.isTouchMove && diff --git a/src/hooks/utils.js b/src/hooks/utils.js index b2c583c9..b0e7dd4c 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -390,7 +390,13 @@ function useMouseAndTouchTracker( mouseAndTouchTrackersRef.current.isMouseDown = false if ( - !targetWithinDownshift(event.target, downshiftElements, environment) + !targetWithinDownshift( + event.target, + downshiftElements, + environment, + true, + 'composedPath' in event && event.composedPath(), + ) ) { handleBlur() } @@ -412,6 +418,7 @@ function useMouseAndTouchTracker( downshiftElements, environment, false, + 'composedPath' in event && event.composedPath(), ) ) { handleBlur() diff --git a/src/utils.js b/src/utils.js index 23b99223..7c754ff2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -417,6 +417,7 @@ function getNonDisabledIndex( * @param {HTMLElement[]} downshiftElements The elements that form downshift (list, toggle button etc). * @param {Window} environment The window context where downshift renders. * @param {boolean} checkActiveElement Whether to also check activeElement. + * @param {boolean} composedPath Whether to check the composed path. * * @returns {boolean} Whether or not the target is within downshift elements. */ @@ -425,20 +426,34 @@ function targetWithinDownshift( downshiftElements, environment, checkActiveElement = true, + composedPath, ) { - return ( - environment && - downshiftElements.some( - contextNode => - contextNode && - (isOrContainsNode(contextNode, target, environment) || + if (!environment) { + return false + } + + // Find the real activeElement by drilling through shadow roots + let activeElement = environment.document.activeElement + while ( + activeElement != null && + activeElement.shadowRoot != null && + activeElement.shadowRoot.activeElement != null + ) { + activeElement = activeElement.shadowRoot.activeElement + } + + return downshiftElements.some( + contextNode => + contextNode && + (composedPath + ? // Check if the contextNode is in the event's composed path + composedPath.indexOf(contextNode) !== -1 || (checkActiveElement && - isOrContainsNode( - contextNode, - environment.document.activeElement, - environment, - ))), - ) + isOrContainsNode(contextNode, activeElement, environment)) + : // Fall back to regular DOM traversal when composedPath not available + isOrContainsNode(contextNode, target, environment) || + (checkActiveElement && + isOrContainsNode(contextNode, activeElement, environment))), ) } diff --git a/test/useCombobox.test.tsx b/test/useCombobox.test.tsx index 08cd8528..b9c6e22b 100644 --- a/test/useCombobox.test.tsx +++ b/test/useCombobox.test.tsx @@ -52,7 +52,7 @@ export default function DropdownCombobox() { {isOpen ? <>↑ : <>↓} + +

Shadow DOM

+
+ + + +
+
+ +
) } diff --git a/docusaurus/pages/shadow-dom/useSelect.js b/docusaurus/pages/shadow-dom/useSelect.js index 53f99dcd..d7c0d313 100644 --- a/docusaurus/pages/shadow-dom/useSelect.js +++ b/docusaurus/pages/shadow-dom/useSelect.js @@ -4,7 +4,9 @@ import DropdownSelect from '../useSelect' import {ReactShadowRoot} from '../../../test/react-shadow' export default function DropdownSelectShadow() { - return - - + return ( + + + + ) } diff --git a/src/__tests__/.eslintrc b/src/__tests__/.eslintrc index 4e44adf7..ec672d99 100644 --- a/src/__tests__/.eslintrc +++ b/src/__tests__/.eslintrc @@ -5,6 +5,7 @@ "react/prop-types": "off", "react/display-name": "off", "react/no-deprecated": "off", - "no-console": "off" - } + "no-console": "off", + "no-unused-vars": "off", + }, } diff --git a/src/__tests__/downshift.shadow-root.js b/src/__tests__/downshift.shadow-root.js index 8dee3383..4ff0f709 100644 --- a/src/__tests__/downshift.shadow-root.js +++ b/src/__tests__/downshift.shadow-root.js @@ -7,29 +7,32 @@ import DropdownCombobox, {colors} from '../../test/useCombobox.test' import DropdownMultipleSelect from '../../test/useMultipleSelect.test' import {ReactShadowRoot} from '../../test/react-shadow' - function _queryAllByRoleDeep(container, ...rest) { - // eslint-disable-next-line testing-library/prefer-screen-queries - const result = queryAllByRole(container, ...rest) // replace here with different queryAll* variants. - for (const element of container.querySelectorAll('*')) { - if (element.shadowRoot) { - result.push(..._queryAllByRoleDeep(element.shadowRoot, ...rest)) - } + // eslint-disable-next-line testing-library/prefer-screen-queries + const result = queryAllByRole(container, ...rest) // replace here with different queryAll* variants. + for (const element of container.querySelectorAll('*')) { + if (element.shadowRoot) { + result.push(..._queryAllByRoleDeep(element.shadowRoot, ...rest)) } - - return result } - - // eslint-disable-next-line no-unused-vars - const [_queryByRoleDeep, _getAllByRoleDeep, _getByRoleDeep, _findAllByRoleDeep, _findByRoleDeep] = buildQueries( - _queryAllByRoleDeep, - (_, role) => `Found multiple elements with the role ${role}`, - (_, role) => `Unable to find an element with the role ${role}` - ) - const getAllByRoleDeep = _getAllByRoleDeep.bind(null, document.body) - const getByRoleDeep = _getByRoleDeep.bind(null, document.body) + return result +} +const [ + _queryByRoleDeep, + _getAllByRoleDeep, + _getByRoleDeep, + _findAllByRoleDeep, + _findByRoleDeep, +] = buildQueries( + _queryAllByRoleDeep, + (_, role) => `Found multiple elements with the role ${role}`, + (_, role) => `Unable to find an element with the role ${role}`, +) + +const getAllByRoleDeep = _getAllByRoleDeep.bind(null, document.body) +const getByRoleDeep = _getByRoleDeep.bind(null, document.body) const Wrapper = ({children}) => { return {children} @@ -65,37 +68,37 @@ test('DropdownSelect works correctly in shadow DOM', async () => { // Verify shadow root exists expect(container.shadowRoot).toBeDefined() - + // Get elements within the shadow root const toggleButton = getByRoleDeep('combobox') const menu = getByRoleDeep('listbox') expect(toggleButton).toBeInTheDocument() expect(menu).toBeInTheDocument() - + // Initially menu should be closed expect(toggleButton).toHaveAttribute('aria-expanded', 'false') - + // Open the dropdown await user.click(toggleButton) - + // Menu should now be open expect(toggleButton).toHaveAttribute('aria-expanded', 'true') - + // Select an item const blackOption = getByRoleDeep('option', {name: 'Black'}) await user.click(blackOption) - + // Menu should close and selected item should appear in button expect(toggleButton).toHaveAttribute('aria-expanded', 'false') expect(toggleButton).toHaveTextContent('Black') - + // Open it again await user.click(toggleButton) expect(toggleButton).toHaveAttribute('aria-expanded', 'true') - + // Click outside (this tests our targetWithinDownshift with composedPath) await user.click(document.body) - + // Menu should close expect(toggleButton).toHaveAttribute('aria-expanded', 'false') }) @@ -106,65 +109,53 @@ test('DropdownCombobox works correctly in shadow DOM', async () => { // Verify shadow root exists expect(container.shadowRoot).toBeDefined() - + // Get elements within the shadow root const input = getByRoleDeep('combobox') const toggleButton = getByRoleDeep('button', {name: 'toggle menu'}) const clearButton = getByRoleDeep('button', {name: 'clear'}) const menu = getByRoleDeep('listbox') - + expect(input).toBeInTheDocument() expect(toggleButton).toBeInTheDocument() expect(clearButton).toBeInTheDocument() expect(menu).toBeInTheDocument() - + // Initially menu should be closed expect(input).toHaveAttribute('aria-expanded', 'false') expect(toggleButton).toHaveAttribute('aria-expanded', 'false') - + // Open the dropdown await user.click(toggleButton) - + // Menu should now be open expect(input).toHaveAttribute('aria-expanded', 'true') - + // All colors should initially be visible const items = getAllByRoleDeep('option') expect(items).toHaveLength(colors.length) - -// // Type in the input to filter items -// await user.click(input) -// input.focus() -// expect(input).toHaveFocus() -// await user.type(input, 'bl') - -// // Only Black and Blue should be visible -// items = getAllByRoleDeep('option') -// await waitFor(() => expect(items).toHaveLength(2)) -// expect(items[0]).toHaveTextContent('Black') -// expect(items[1]).toHaveTextContent('Blue') - + // Select an item await user.click(items[0]) - + // Menu should close and input should have selected value expect(input).toHaveAttribute('aria-expanded', 'false') expect(toggleButton).toHaveAttribute('aria-expanded', 'false') expect(input).toHaveValue('Black') - + // Clear the selection await user.click(clearButton) - + // Input should be empty expect(input).toHaveValue('') - + // Open it again await user.click(toggleButton) expect(input).toHaveAttribute('aria-expanded', 'true') - + // Click outside to close await user.click(document.body) - + // Menu should close expect(input).toHaveAttribute('aria-expanded', 'false') }) diff --git a/src/__tests__/utils.target-within-downshift.js b/src/__tests__/utils.target-within-downshift.js new file mode 100644 index 00000000..4621c494 --- /dev/null +++ b/src/__tests__/utils.target-within-downshift.js @@ -0,0 +1,59 @@ +import {targetWithinDownshift} from '../utils' + +test('returns false if environment is not defined', () => { + expect(targetWithinDownshift(null, [], null)).toBe(false) +}) + +test('returns false if the target is not within the downshift', () => { + const downshift = document.createElement('div') + downshift.innerHTML = '
' + expect(targetWithinDownshift(null, [downshift], window)).toBe(false) +}) + +test('correctly identifies active elements nested in shadow DOM', () => { + // Create a host element for the shadow DOM + const hostElement = document.createElement('div') + document.body.appendChild(hostElement) + + // Attach a shadow root to the host element + const shadowRoot = hostElement.attachShadow({mode: 'open'}) + + // Create an element inside the shadow root + const innerElement = document.createElement('button') + innerElement.setAttribute('data-testid', 'inner-button') + shadowRoot.appendChild(innerElement) + + // Create another nested level with another shadow root + const nestedHost = document.createElement('div') + shadowRoot.appendChild(nestedHost) + + const nestedShadowRoot = nestedHost.attachShadow({mode: 'open'}) + + const deepActiveElement = document.createElement('button') + + // Create some downshift elements + const downshiftElement = document.createElement('div') + nestedShadowRoot.appendChild(downshiftElement) + downshiftElement.appendChild(deepActiveElement) + + // Focus the deep element to make it the active element + deepActiveElement.focus() + + expect(hostElement.shadowRoot).toBe(shadowRoot) + expect(nestedHost.shadowRoot).toBe(nestedShadowRoot) + expect(nestedShadowRoot.activeElement).toBe(deepActiveElement) + + // Test the function with the active element in the shadow DOM + expect( + targetWithinDownshift(deepActiveElement, [downshiftElement], window, true), + ).toBe(true) + + // Test with a non-related element + const unrelatedElement = document.createElement('div') + expect( + targetWithinDownshift(unrelatedElement, [downshiftElement], window, true), + ).toBe(true) // Should still be true because of activeElement check + + // Cleanup + document.body.removeChild(hostElement) +}) diff --git a/src/utils.js b/src/utils.js index 7c754ff2..efcbbff1 100644 --- a/src/utils.js +++ b/src/utils.js @@ -417,7 +417,7 @@ function getNonDisabledIndex( * @param {HTMLElement[]} downshiftElements The elements that form downshift (list, toggle button etc). * @param {Window} environment The window context where downshift renders. * @param {boolean} checkActiveElement Whether to also check activeElement. - * @param {boolean} composedPath Whether to check the composed path. + * @param {EventTarget[]} composedPath The composed path of the event. * * @returns {boolean} Whether or not the target is within downshift elements. */ diff --git a/test/react-shadow.js b/test/react-shadow.js index 482b987d..bc1a9cde 100644 --- a/test/react-shadow.js +++ b/test/react-shadow.js @@ -25,6 +25,7 @@ const shadowRootSupported = * @param {boolean} props.delegatesFocus Expands the focus behavior of elements within the shadow DOM. * @param {string} props.mode Sets the mode of the shadow root. (open or closed) * @param {CSSStyleSheet[]} props.stylesheets Takes an array of CSSStyleSheet objects for constructable stylesheets. + * @param {React.ReactNode} props.children The children of the component */ const ReactShadowRoot = ({ declarative = false, From 886b569cc037b6836b97ed89bf26ecc496659355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Ba=CC=88chler?= Date: Wed, 14 May 2025 12:50:37 +0200 Subject: [PATCH 4/4] Add test for search --- cypress/e2e/useMultipleCombobox.cy.js | 23 +++- docusaurus/pages/shadow-dom/useCombobox.js | 21 +++ .../pages/shadow-dom/useMultipleCombobox.js | 6 +- src/__tests__/downshift.shadow-root.js | 121 ++++++++++++++++++ src/downshift.js | 21 ++- src/hooks/useCombobox/index.js | 11 +- test/react-shadow.js | 10 +- 7 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 docusaurus/pages/shadow-dom/useCombobox.js diff --git a/cypress/e2e/useMultipleCombobox.cy.js b/cypress/e2e/useMultipleCombobox.cy.js index 3ca27299..dfc05dea 100644 --- a/cypress/e2e/useMultipleCombobox.cy.js +++ b/cypress/e2e/useMultipleCombobox.cy.js @@ -26,7 +26,7 @@ describe('useMultipleCombobox', () => { }) describe('useMultipleCombobox in shadow DOM', () => { - before(() => { + beforeEach(() => { cy.visit('/shadow-dom/useMultipleCombobox') }) @@ -54,4 +54,25 @@ describe('useMultipleCombobox in shadow DOM', () => { cy.findByText('Gray').should('be.visible') }) }) + + it('can filter the items', () => { + cy.get('[data-testid="shadow-root"]') + .shadow() + .within(() => { + cy.findByRole('button', {name: 'toggle menu'}).click() + cy.findAllByRole('option').should('have.length', 12) + cy.findByRole('combobox').type('g') + cy.findByRole('button', {name: 'toggle menu'}).should( + 'have.attr', + 'aria-expanded', + 'true', + ) + cy.findAllByRole('option').should('have.length', 2) + cy.findByText('Green').should('be.visible') + cy.findByText('Gray').should('be.visible') + + cy.findByRole('combobox').type('{backspace}') + cy.findAllByRole('option').should('have.length', 12) + }) + }) }) diff --git a/docusaurus/pages/shadow-dom/useCombobox.js b/docusaurus/pages/shadow-dom/useCombobox.js new file mode 100644 index 00000000..424959b5 --- /dev/null +++ b/docusaurus/pages/shadow-dom/useCombobox.js @@ -0,0 +1,21 @@ +import * as React from 'react' + +import DropdownCombobox from '../useCombobox' +import {ReactShadowRoot} from '../../../test/react-shadow' + +const style = { + padding: '20px', +} + +export default function MultipleComboboxShadow() { + return ( +
+

Shadow DOM

+
+ + + +
+
+ ) +} diff --git a/docusaurus/pages/shadow-dom/useMultipleCombobox.js b/docusaurus/pages/shadow-dom/useMultipleCombobox.js index 0104e97d..f7b2359c 100644 --- a/docusaurus/pages/shadow-dom/useMultipleCombobox.js +++ b/docusaurus/pages/shadow-dom/useMultipleCombobox.js @@ -3,9 +3,13 @@ import * as React from 'react' import DropdownMultipleCombobox from '../useMultipleCombobox' import {ReactShadowRoot} from '../../../test/react-shadow' +const style = { + padding: '20px', +} + export default function MultipleComboboxShadow() { return ( -
+
diff --git a/src/__tests__/downshift.shadow-root.js b/src/__tests__/downshift.shadow-root.js index 4ff0f709..e7661041 100644 --- a/src/__tests__/downshift.shadow-root.js +++ b/src/__tests__/downshift.shadow-root.js @@ -5,6 +5,7 @@ import Downshift from '../' import DropdownSelect from '../../test/useSelect.test' import DropdownCombobox, {colors} from '../../test/useCombobox.test' import DropdownMultipleSelect from '../../test/useMultipleSelect.test' +import ComboBox from '../../test/downshift.test' import {ReactShadowRoot} from '../../test/react-shadow' function _queryAllByRoleDeep(container, ...rest) { @@ -159,3 +160,123 @@ test('DropdownCombobox works correctly in shadow DOM', async () => { // Menu should close expect(input).toHaveAttribute('aria-expanded', 'false') }) + +test('Downshift button blur correctly handles focus moving to external shadow DOM', async () => { + const user = userEvent.setup() + const {container} = render(, {wrapper: Wrapper}) + + // Verify Downshift's own shadow root exists + expect(container.shadowRoot).toBeDefined() + + const comboboxRoot = getByRoleDeep('combobox') + const toggleButton = container.shadowRoot.querySelector( + '[data-testid="combobox-toggle-button"]', + ) + + // Open the dropdown + await user.click(toggleButton) + expect(comboboxRoot).toHaveAttribute('aria-expanded', 'true') + + // Click the element inside the external shadow DOM + // This should focus externalFocusableButton and blur toggleButton (or the root/input depending on what had focus) + const externalHost = document.createElement('div') + document.body.appendChild(externalHost) + const shadow = externalHost.attachShadow({mode: 'open'}) + const externalFocusableButton = document.createElement('button') + shadow.appendChild(externalFocusableButton) + await user.click(externalFocusableButton) + + // Assert that the menu closes due to blur + // Downshift's blur handlers use setTimeout, so wait for the next macrotask + await new Promise(resolve => setTimeout(resolve, 0)) + expect(comboboxRoot).toHaveAttribute('aria-expanded', 'false') + + // Cleanup + document.body.removeChild(externalHost) +}) + +test('Downshift input blur correctly handles focus moving to external shadow DOM', async () => { + const user = userEvent.setup() + const {container} = render(, {wrapper: Wrapper}) + + // Verify Downshift's own shadow root exists + expect(container.shadowRoot).toBeDefined() + + const comboboxRoot = getByRoleDeep('combobox') + const inputField = container.shadowRoot.querySelector( + '[data-testid="combobox-input"]', + ) + const downshiftToggleButton = container.shadowRoot.querySelector( + '[data-testid="combobox-toggle-button"]', + ) + + // Create an external element with its own shadow DOM + const externalHost = document.createElement('div') + document.body.appendChild(externalHost) + const shadow = externalHost.attachShadow({mode: 'open'}) + const externalFocusableButton = document.createElement('button') + shadow.appendChild(externalFocusableButton) + + // Open the dropdown by clicking the toggle button + await user.click(downshiftToggleButton) + expect(comboboxRoot).toHaveAttribute('aria-expanded', 'true') + + // Ensure the input itself is focused before it blurs + inputField.focus() + await user.type(inputField, 'b') + await user.keyboard('{Tab}') + // Click the element inside the external shadow DOM + // This should focus externalFocusableButton and blur the input + await user.click(externalFocusableButton) + + // Assert that the menu closes due to blur + // Downshift's blur handlers use setTimeout, so wait for the next macrotask + await new Promise(resolve => setTimeout(resolve, 0)) + expect(comboboxRoot).toHaveAttribute('aria-expanded', 'false') + + // Cleanup + document.body.removeChild(externalHost) +}) + +test('useCombobox input blur correctly handles focus moving to external shadow DOM', async () => { + const user = userEvent.setup() + const {container} = render(, {wrapper: Wrapper}) + + // Verify DropdownCombobox's own shadow root exists via the Wrapper + expect(container.shadowRoot).toBeDefined() + + const input = container.shadowRoot.querySelector( + '[data-testid="combobox-input"]', + ) + const toggleButton = container.shadowRoot.querySelector( + '[data-testid="combobox-toggle-button"]', + ) + + // Create an external element with its own shadow DOM + const externalHost = document.createElement('div') + document.body.appendChild(externalHost) + const shadow = externalHost.attachShadow({mode: 'open'}) + const externalFocusableButton = document.createElement('button') + shadow.appendChild(externalFocusableButton) + + // Open the dropdown by clicking the toggle button + await user.click(toggleButton) + expect(input).toHaveAttribute('aria-expanded', 'true') + + // Ensure the input itself is focused before it blurs + input.focus() + await user.type(input, 'b') + await user.keyboard('{Tab}') + + // Click the element inside the external shadow DOM + // This should focus externalFocusableButton and blur the input in DropdownCombobox + await user.click(externalFocusableButton) + + // Assert that the menu closes due to blur + // useCombobox's blur handler uses setTimeout, so wait for the next macrotask + await new Promise(resolve => setTimeout(resolve, 0)) + expect(input).toHaveAttribute('aria-expanded', 'false') + + // Cleanup + document.body.removeChild(externalHost) +}) diff --git a/src/downshift.js b/src/downshift.js index c2802685..af7cb9e3 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -764,7 +764,16 @@ class Downshift extends Component { return } - const {activeElement} = this.props.environment.document + let {activeElement} = this.props.environment.document + + // find the real activeElement not a custom element with a shadowRoot + /* istanbul ignore next -- JSDOM always reports the focused element as document.activeElement */ + while ( + activeElement?.shadowRoot && + activeElement.shadowRoot.activeElement != null + ) { + activeElement = activeElement.shadowRoot.activeElement + } if ( (activeElement == null || activeElement.id !== this.inputId) && @@ -877,7 +886,15 @@ class Downshift extends Component { return } - const {activeElement} = this.props.environment.document + let {activeElement} = this.props.environment.document + /* istanbul ignore next -- JSDOM always reports the focused element as document.activeElement */ + while ( + activeElement?.shadowRoot && + activeElement.shadowRoot.activeElement != null + ) { + activeElement = activeElement.shadowRoot.activeElement + } + const downshiftButtonIsActive = activeElement?.dataset?.toggle && this._rootNode && diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index d9e357c8..f078bfa5 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -129,8 +129,17 @@ function useCombobox(userProps = {}) { if (!isOpen || !environment?.document || !inputRef?.current?.focus) { return } + let {activeElement} = environment.document + // find the real activeElement not a custom element with a shadowRoot + /* istanbul ignore next -- JSDOM always reports the focused element as document.activeElement */ + while ( + activeElement?.shadowRoot && + activeElement.shadowRoot.activeElement != null + ) { + activeElement = activeElement.shadowRoot.activeElement + } - if (environment.document.activeElement !== inputRef.current) { + if (activeElement !== inputRef.current) { inputRef.current.focus() } }, [isOpen, environment]) diff --git a/test/react-shadow.js b/test/react-shadow.js index bc1a9cde..5b4de84d 100644 --- a/test/react-shadow.js +++ b/test/react-shadow.js @@ -21,11 +21,11 @@ const shadowRootSupported = /** * @param {object} props Properties passed to the component - * @param {boolean} props.declarative When true, uses a declarative shadow root - * @param {boolean} props.delegatesFocus Expands the focus behavior of elements within the shadow DOM. - * @param {string} props.mode Sets the mode of the shadow root. (open or closed) - * @param {CSSStyleSheet[]} props.stylesheets Takes an array of CSSStyleSheet objects for constructable stylesheets. - * @param {React.ReactNode} props.children The children of the component + * @param {boolean} [props.declarative] When true, uses a declarative shadow root + * @param {boolean} [props.delegatesFocus] Expands the focus behavior of elements within the shadow DOM. + * @param {string} [props.mode] Sets the mode of the shadow root. (open or closed) + * @param {CSSStyleSheet[]} [props.stylesheets] Takes an array of CSSStyleSheet objects for constructable stylesheets. + * @param {React.ReactNode} props.children The component to render within the shadow root. */ const ReactShadowRoot = ({ declarative = false,