Skip to content

Commit a06a26b

Browse files
committed
Patch DOM traversal
1 parent b0b9261 commit a06a26b

File tree

5 files changed

+176
-16
lines changed

5 files changed

+176
-16
lines changed

src/__tests__/downshift.shadow-root.js

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,36 @@
11
import * as React from 'react'
2-
import {render} from '@testing-library/react'
2+
import {render, buildQueries, queryAllByRole} from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
34
import Downshift from '../'
45
import DropdownSelect from '../../test/useSelect.test'
5-
import DropdownCombobox from '../../test/useCombobox.test'
6+
import DropdownCombobox, {colors} from '../../test/useCombobox.test'
67
import DropdownMultipleSelect from '../../test/useMultipleSelect.test'
78
import {ReactShadowRoot} from '../../test/react-shadow'
89

10+
11+
function _queryAllByRoleDeep(container, ...rest) {
12+
// eslint-disable-next-line testing-library/prefer-screen-queries
13+
const result = queryAllByRole(container, ...rest) // replace here with different queryAll* variants.
14+
for (const element of container.querySelectorAll('*')) {
15+
if (element.shadowRoot) {
16+
result.push(..._queryAllByRoleDeep(element.shadowRoot, ...rest))
17+
}
18+
}
19+
20+
return result
21+
}
22+
23+
// eslint-disable-next-line no-unused-vars
24+
const [_queryByRoleDeep, _getAllByRoleDeep, _getByRoleDeep, _findAllByRoleDeep, _findByRoleDeep] = buildQueries(
25+
_queryAllByRoleDeep,
26+
(_, role) => `Found multiple elements with the role ${role}`,
27+
(_, role) => `Unable to find an element with the role ${role}`
28+
)
29+
30+
const getAllByRoleDeep = _getAllByRoleDeep.bind(null, document.body)
31+
const getByRoleDeep = _getByRoleDeep.bind(null, document.body)
32+
33+
934
const Wrapper = ({children}) => {
1035
return <ReactShadowRoot>{children}</ReactShadowRoot>
1136
}
@@ -33,3 +58,113 @@ test('DropdownMultipleSelect renders with a shadow root', () => {
3358

3459
expect(container.shadowRoot).toBeDefined()
3560
})
61+
62+
test('DropdownSelect works correctly in shadow DOM', async () => {
63+
const user = userEvent.setup()
64+
const {container} = render(<DropdownSelect />, {wrapper: Wrapper})
65+
66+
// Verify shadow root exists
67+
expect(container.shadowRoot).toBeDefined()
68+
69+
// Get elements within the shadow root
70+
const toggleButton = getByRoleDeep('combobox')
71+
const menu = getByRoleDeep('listbox')
72+
expect(toggleButton).toBeInTheDocument()
73+
expect(menu).toBeInTheDocument()
74+
75+
// Initially menu should be closed
76+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
77+
78+
// Open the dropdown
79+
await user.click(toggleButton)
80+
81+
// Menu should now be open
82+
expect(toggleButton).toHaveAttribute('aria-expanded', 'true')
83+
84+
// Select an item
85+
const blackOption = getByRoleDeep('option', {name: 'Black'})
86+
await user.click(blackOption)
87+
88+
// Menu should close and selected item should appear in button
89+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
90+
expect(toggleButton).toHaveTextContent('Black')
91+
92+
// Open it again
93+
await user.click(toggleButton)
94+
expect(toggleButton).toHaveAttribute('aria-expanded', 'true')
95+
96+
// Click outside (this tests our targetWithinDownshift with composedPath)
97+
await user.click(document.body)
98+
99+
// Menu should close
100+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
101+
})
102+
103+
test('DropdownCombobox works correctly in shadow DOM', async () => {
104+
const user = userEvent.setup()
105+
const {container} = render(<DropdownCombobox />, {wrapper: Wrapper})
106+
107+
// Verify shadow root exists
108+
expect(container.shadowRoot).toBeDefined()
109+
110+
// Get elements within the shadow root
111+
const input = getByRoleDeep('combobox')
112+
const toggleButton = getByRoleDeep('button', {name: 'toggle menu'})
113+
const clearButton = getByRoleDeep('button', {name: 'clear'})
114+
const menu = getByRoleDeep('listbox')
115+
116+
expect(input).toBeInTheDocument()
117+
expect(toggleButton).toBeInTheDocument()
118+
expect(clearButton).toBeInTheDocument()
119+
expect(menu).toBeInTheDocument()
120+
121+
// Initially menu should be closed
122+
expect(input).toHaveAttribute('aria-expanded', 'false')
123+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
124+
125+
// Open the dropdown
126+
await user.click(toggleButton)
127+
128+
// Menu should now be open
129+
expect(input).toHaveAttribute('aria-expanded', 'true')
130+
131+
// All colors should initially be visible
132+
const items = getAllByRoleDeep('option')
133+
expect(items).toHaveLength(colors.length)
134+
135+
// // Type in the input to filter items
136+
// await user.click(input)
137+
// input.focus()
138+
// expect(input).toHaveFocus()
139+
// await user.type(input, 'bl')
140+
141+
// // Only Black and Blue should be visible
142+
// items = getAllByRoleDeep('option')
143+
// await waitFor(() => expect(items).toHaveLength(2))
144+
// expect(items[0]).toHaveTextContent('Black')
145+
// expect(items[1]).toHaveTextContent('Blue')
146+
147+
// Select an item
148+
await user.click(items[0])
149+
150+
// Menu should close and input should have selected value
151+
expect(input).toHaveAttribute('aria-expanded', 'false')
152+
expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
153+
expect(input).toHaveValue('Black')
154+
155+
// Clear the selection
156+
await user.click(clearButton)
157+
158+
// Input should be empty
159+
expect(input).toHaveValue('')
160+
161+
// Open it again
162+
await user.click(toggleButton)
163+
expect(input).toHaveAttribute('aria-expanded', 'true')
164+
165+
// Click outside to close
166+
await user.click(document.body)
167+
168+
// Menu should close
169+
expect(input).toHaveAttribute('aria-expanded', 'false')
170+
})

src/downshift.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,8 @@ class Downshift extends Component {
10931093
event.target,
10941094
[this._rootNode, this._menuNode],
10951095
this.props.environment,
1096+
true,
1097+
'composedPath' in event && event.composedPath(),
10961098
)
10971099
if (!contextWithinDownshift && this.getState().isOpen) {
10981100
this.reset({type: stateChangeTypes.mouseUp}, () =>
@@ -1120,6 +1122,7 @@ class Downshift extends Component {
11201122
[this._rootNode, this._menuNode],
11211123
this.props.environment,
11221124
false,
1125+
'composedPath' in event && event.composedPath(),
11231126
)
11241127
if (
11251128
!this.isTouchMove &&

src/hooks/utils.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,13 @@ function useMouseAndTouchTracker(
390390
mouseAndTouchTrackersRef.current.isMouseDown = false
391391

392392
if (
393-
!targetWithinDownshift(event.target, downshiftElements, environment)
393+
!targetWithinDownshift(
394+
event.target,
395+
downshiftElements,
396+
environment,
397+
true,
398+
'composedPath' in event && event.composedPath(),
399+
)
394400
) {
395401
handleBlur()
396402
}
@@ -412,6 +418,7 @@ function useMouseAndTouchTracker(
412418
downshiftElements,
413419
environment,
414420
false,
421+
'composedPath' in event && event.composedPath(),
415422
)
416423
) {
417424
handleBlur()

src/utils.js

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ function getNonDisabledIndex(
417417
* @param {HTMLElement[]} downshiftElements The elements that form downshift (list, toggle button etc).
418418
* @param {Window} environment The window context where downshift renders.
419419
* @param {boolean} checkActiveElement Whether to also check activeElement.
420+
* @param {boolean} composedPath Whether to check the composed path.
420421
*
421422
* @returns {boolean} Whether or not the target is within downshift elements.
422423
*/
@@ -425,20 +426,34 @@ function targetWithinDownshift(
425426
downshiftElements,
426427
environment,
427428
checkActiveElement = true,
429+
composedPath,
428430
) {
429-
return (
430-
environment &&
431-
downshiftElements.some(
432-
contextNode =>
433-
contextNode &&
434-
(isOrContainsNode(contextNode, target, environment) ||
431+
if (!environment) {
432+
return false
433+
}
434+
435+
// Find the real activeElement by drilling through shadow roots
436+
let activeElement = environment.document.activeElement
437+
while (
438+
activeElement != null &&
439+
activeElement.shadowRoot != null &&
440+
activeElement.shadowRoot.activeElement != null
441+
) {
442+
activeElement = activeElement.shadowRoot.activeElement
443+
}
444+
445+
return downshiftElements.some(
446+
contextNode =>
447+
contextNode &&
448+
(composedPath
449+
? // Check if the contextNode is in the event's composed path
450+
composedPath.indexOf(contextNode) !== -1 ||
435451
(checkActiveElement &&
436-
isOrContainsNode(
437-
contextNode,
438-
environment.document.activeElement,
439-
environment,
440-
))),
441-
)
452+
isOrContainsNode(contextNode, activeElement, environment))
453+
: // Fall back to regular DOM traversal when composedPath not available
454+
isOrContainsNode(contextNode, target, environment) ||
455+
(checkActiveElement &&
456+
isOrContainsNode(contextNode, activeElement, environment))),
442457
)
443458
}
444459

test/useCombobox.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default function DropdownCombobox() {
5252
{isOpen ? <>&#8593;</> : <>&#8595;</>}
5353
</button>
5454
<button
55-
aria-label="toggle menu"
55+
aria-label="clear"
5656
data-testid="clear-button"
5757
onClick={() => selectItem(null)}
5858
>

0 commit comments

Comments
 (0)