diff --git a/cypress/e2e/useMultipleCombobox.cy.js b/cypress/e2e/useMultipleCombobox.cy.js index 20fec955..dfc05dea 100644 --- a/cypress/e2e/useMultipleCombobox.cy.js +++ b/cypress/e2e/useMultipleCombobox.cy.js @@ -6,11 +6,73 @@ 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', () => { + beforeEach(() => { + 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') + }) + }) + + 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 new file mode 100644 index 00000000..f7b2359c --- /dev/null +++ b/docusaurus/pages/shadow-dom/useMultipleCombobox.js @@ -0,0 +1,27 @@ +import * as React from 'react' + +import DropdownMultipleCombobox from '../useMultipleCombobox' +import {ReactShadowRoot} from '../../../test/react-shadow' + +const style = { + padding: '20px', +} + +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..d7c0d313 --- /dev/null +++ b/docusaurus/pages/shadow-dom/useSelect.js @@ -0,0 +1,12 @@ +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__/.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 new file mode 100644 index 00000000..e7661041 --- /dev/null +++ b/src/__tests__/downshift.shadow-root.js @@ -0,0 +1,282 @@ +import * as React from '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, {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) { + // 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 +} + +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} +} + +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() +}) + +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) + + // 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') +}) + +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/__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/downshift.js b/src/downshift.js index 49bdda0f..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 && @@ -1093,6 +1110,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 +1139,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/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/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..efcbbff1 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 {EventTarget[]} composedPath The composed path of the event. * * @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/react-shadow.js b/test/react-shadow.js new file mode 100644 index 00000000..5b4de84d --- /dev/null +++ b/test/react-shadow.js @@ -0,0 +1,74 @@ +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. + * @param {React.ReactNode} props.children The component to render within the shadow root. + */ +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} 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 ? <>↑ : <>↓}