Skip to content

Commit 82beae9

Browse files
authored
fix(tile): improve interactive element detection to include aria-role (#14239)
* fix(tile): improve interactive element detection to include aria-role * fix(expandable-tile): ensure aria-controls on button controlling belowTheFold content * test(tile): update tests for aria-controls in expandable tile
1 parent 96b874c commit 82beae9

3 files changed

Lines changed: 122 additions & 8 deletions

File tree

packages/react/src/components/Tile/Tile-test.js

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe('Tile', () => {
183183

184184
it('toggles the expandable class on click', async () => {
185185
const onClick = jest.fn();
186-
render(
186+
const { container } = render(
187187
<ExpandableTile onClick={onClick}>
188188
<TileAboveTheFoldContent>
189189
<div>TestAbove</div>
@@ -193,6 +193,7 @@ describe('Tile', () => {
193193
</TileBelowTheFoldContent>
194194
</ExpandableTile>
195195
);
196+
expect(container.firstChild.nodeName).toBe('BUTTON');
196197
expect(screen.getByRole('button')).not.toHaveClass(
197198
`${prefix}--tile--is-expanded`
198199
);
@@ -280,4 +281,78 @@ describe('Tile', () => {
280281
);
281282
});
282283
});
284+
285+
describe('ExpandableTile with interactive elements', () => {
286+
it('does not render the tile as a button and expands/collapses', async () => {
287+
const onClick = jest.fn();
288+
const { container } = render(
289+
<ExpandableTile onClick={onClick}>
290+
<TileAboveTheFoldContent>
291+
<button type="button">TestAbove</button>
292+
</TileAboveTheFoldContent>
293+
<TileBelowTheFoldContent>
294+
<button type="button">TestBelow</button>
295+
</TileBelowTheFoldContent>
296+
</ExpandableTile>
297+
);
298+
299+
const tile = container.firstChild;
300+
const expandButton = screen.getByRole('button', {
301+
name: 'Interact to expand Tile',
302+
});
303+
304+
expect(tile.nodeName).not.toBe('BUTTON');
305+
expect(tile).toContainElement(expandButton);
306+
expect(tile).not.toHaveAttribute('aria-expanded');
307+
308+
expect(expandButton).toHaveAttribute('aria-expanded', 'false');
309+
expect(expandButton).toHaveAttribute(
310+
'aria-controls',
311+
expect.stringContaining('expandable-tile-interactive')
312+
);
313+
314+
await userEvent.click(expandButton);
315+
316+
expect(onClick).toHaveBeenCalled();
317+
expect(expandButton).toHaveAttribute('aria-expanded', 'true');
318+
});
319+
});
320+
321+
describe('ExpandableTile with role elements', () => {
322+
it('does not render the tile as a button and expands/collapses', async () => {
323+
const onClick = jest.fn();
324+
const { container } = render(
325+
<ExpandableTile onClick={onClick}>
326+
<TileAboveTheFoldContent>
327+
<div role="table" className="testing">
328+
TestAbove
329+
</div>
330+
</TileAboveTheFoldContent>
331+
<TileBelowTheFoldContent>
332+
<div>TestBelow</div>
333+
</TileBelowTheFoldContent>
334+
</ExpandableTile>
335+
);
336+
337+
const tile = container.firstChild;
338+
const expandButton = screen.getByRole('button', {
339+
name: 'Interact to expand Tile',
340+
});
341+
342+
expect(tile.nodeName).not.toBe('BUTTON');
343+
expect(tile).toContainElement(expandButton);
344+
expect(tile).not.toHaveAttribute('aria-expanded');
345+
346+
expect(expandButton).toHaveAttribute('aria-expanded', 'false');
347+
expect(expandButton).toHaveAttribute(
348+
'aria-controls',
349+
expect.stringContaining('expandable-tile-interactive')
350+
);
351+
352+
await userEvent.click(expandButton);
353+
354+
expect(onClick).toHaveBeenCalled();
355+
expect(expandButton).toHaveAttribute('aria-expanded', 'true');
356+
});
357+
});
283358
});

packages/react/src/components/Tile/Tile.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ import deprecate from '../../prop-types/deprecate';
2424
import { composeEventHandlers } from '../../tools/events';
2525
import { usePrefix } from '../../internal/usePrefix';
2626
import useIsomorphicEffect from '../../internal/useIsomorphicEffect';
27-
import { getInteractiveContent } from '../../internal/useNoInteractiveChildren';
27+
import {
28+
getInteractiveContent,
29+
getRoleContent,
30+
} from '../../internal/useNoInteractiveChildren';
2831
import { useMergedRefs } from '../../internal/useMergedRefs';
2932
import { useFeatureFlag } from '../FeatureFlags';
33+
import { useId } from '../../internal/useId';
3034

3135
export interface TileProps extends HTMLAttributes<HTMLDivElement> {
3236
children?: ReactNode;
@@ -663,10 +667,14 @@ export const ExpandableTile = React.forwardRef<
663667
return;
664668
}
665669

666-
if (!getInteractiveContent(belowTheFold.current)) {
667-
setInteractive(false);
668-
return;
669-
} else if (!getInteractiveContent(aboveTheFold.current)) {
670+
// Interactive elements or elements that are given a role should be treated
671+
// the same because elements with a role can not be rendered inside a `button`
672+
if (
673+
!getInteractiveContent(belowTheFold.current) &&
674+
!getRoleContent(belowTheFold.current) &&
675+
!getInteractiveContent(aboveTheFold.current) &&
676+
!getRoleContent(aboveTheFold.current)
677+
) {
670678
setInteractive(false);
671679
}
672680
}, []);
@@ -697,12 +705,13 @@ export const ExpandableTile = React.forwardRef<
697705
return () => resizeObserver.disconnect();
698706
}, []);
699707

708+
const belowTheFoldId = useId('expandable-tile-interactive');
709+
700710
return interactive ? (
701711
<div
702712
// @ts-expect-error: Needlesly strict & deep typing for the element type
703713
ref={ref}
704714
className={interactiveClassNames}
705-
aria-expanded={isExpanded}
706715
{...rest}>
707716
<div ref={tileContent}>
708717
<div ref={aboveTheFold} className={`${prefix}--tile-content`}>
@@ -711,13 +720,17 @@ export const ExpandableTile = React.forwardRef<
711720
<button
712721
type="button"
713722
aria-expanded={isExpanded}
723+
aria-controls={belowTheFoldId}
714724
onKeyUp={composeEventHandlers([onKeyUp, handleKeyUp])}
715725
onClick={composeEventHandlers([onClick, handleClick])}
716726
aria-label={isExpanded ? tileExpandedIconText : tileCollapsedIconText}
717727
className={chevronInteractiveClassNames}>
718728
<ChevronDown />
719729
</button>
720-
<div ref={belowTheFold} className={`${prefix}--tile-content`}>
730+
<div
731+
ref={belowTheFold}
732+
className={`${prefix}--tile-content`}
733+
id={belowTheFoldId}>
721734
{childrenAsArray[1]}
722735
</div>
723736
</div>

packages/react/src/internal/useNoInteractiveChildren.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,32 @@ export function getInteractiveContent(node) {
5151
return null;
5252
}
5353

54+
/**
55+
* Determines if a given DOM node has a role, or has itself a role.
56+
* It returns the node with a role if one is found
57+
*
58+
* @param {HTMLElement} node
59+
* @returns {HTMLElement}
60+
*/
61+
export function getRoleContent(node) {
62+
if (!node || !node.childNodes) {
63+
return null;
64+
}
65+
66+
if (node?.getAttribute?.('role') && node.getAttribute('role') !== '') {
67+
return node;
68+
}
69+
70+
for (const childNode of node.childNodes) {
71+
const roleNode = getRoleContent(childNode);
72+
if (roleNode) {
73+
return roleNode;
74+
}
75+
}
76+
77+
return null;
78+
}
79+
5480
/**
5581
* Determines if the given element is focusable, or not
5682
*

0 commit comments

Comments
 (0)