Skip to content

Commit 75528cf

Browse files
authored
[Flyout System] Automatic aria-labelledby referencing the EuiFlyoutMenu title ID (#9073)
1 parent 4538a3b commit 75528cf

6 files changed

Lines changed: 115 additions & 8 deletions

File tree

packages/eui/src/components/flyout/flyout.component.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export const EuiFlyoutComponent = forwardRef(
252252
children,
253253
as,
254254
hideCloseButton = false,
255-
flyoutMenuProps,
255+
flyoutMenuProps: _flyoutMenuProps,
256256
closeButtonProps,
257257
closeButtonPosition = 'inside',
258258
onClose,
@@ -271,6 +271,7 @@ export const EuiFlyoutComponent = forwardRef(
271271
includeFixedHeadersInFocusTrap = true,
272272
includeSelectorInFocusTrap,
273273
'aria-describedby': _ariaDescribedBy,
274+
'aria-labelledby': _ariaLabelledBy,
274275
id,
275276
resizable = false,
276277
minWidth,
@@ -562,6 +563,25 @@ export const EuiFlyoutComponent = forwardRef(
562563
[hasOverlayMask, descriptionId, focusTrapShards.length]
563564
);
564565

566+
/*
567+
* If the flyout menu is to be rendered, ensure the flyout has aria-labelledby referencing the menu's titleId
568+
*/
569+
const generatedMenuId = useGeneratedHtmlId();
570+
const { titleId: _titleId, ...flyoutMenuProps } = _flyoutMenuProps || {};
571+
const hasMenu = !!_flyoutMenuProps;
572+
573+
const flyoutMenuId = useMemo(() => {
574+
if (!hasMenu) return undefined;
575+
return _titleId || generatedMenuId;
576+
}, [hasMenu, _titleId, generatedMenuId]);
577+
578+
const ariaLabelledBy = useMemo(() => {
579+
if (flyoutMenuId) {
580+
return classnames(flyoutMenuId, _ariaLabelledBy);
581+
}
582+
return _ariaLabelledBy;
583+
}, [flyoutMenuId, _ariaLabelledBy]);
584+
565585
/*
566586
* Trap focus even when `ownFocus={false}`, otherwise closing
567587
* the flyout won't return focus to the originating button.
@@ -634,19 +654,22 @@ export const EuiFlyoutComponent = forwardRef(
634654
aria-modal={!isPushed || undefined}
635655
tabIndex={!isPushed ? 0 : rest.tabIndex}
636656
aria-describedby={!isPushed ? ariaDescribedBy : _ariaDescribedBy}
657+
aria-labelledby={ariaLabelledBy}
637658
data-autofocus={!isPushed || undefined}
638659
onAnimationEnd={onAnimationEnd}
639660
>
640661
{!isPushed && screenReaderDescription}
641-
{!flyoutMenuProps && !hideCloseButton && (
662+
{!_flyoutMenuProps && !hideCloseButton && (
642663
<EuiFlyoutCloseButton
643664
{...closeButtonProps}
644665
onClose={closeFlyout}
645666
closeButtonPosition={closeButtonPosition}
646667
side={side}
647668
/>
648669
)}
649-
{flyoutMenuProps && <EuiFlyoutMenu {...flyoutMenuProps} />}
670+
{_flyoutMenuProps && (
671+
<EuiFlyoutMenu {...flyoutMenuProps} titleId={flyoutMenuId} />
672+
)}
650673
{resizable && (
651674
<EuiFlyoutResizeButton
652675
type={type}

packages/eui/src/components/flyout/flyout.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,89 @@ describe('EuiFlyout', () => {
110110
);
111111
});
112112

113+
describe('aria-labelledby and flyout menu integration', () => {
114+
it('sets aria-labelledby when flyout has a menu with title', () => {
115+
const { getByTestSubject } = render(
116+
<EuiFlyout
117+
onClose={() => {}}
118+
flyoutMenuProps={{ title: 'Test Menu Title' }}
119+
data-test-subj="flyout"
120+
/>
121+
);
122+
123+
const flyout = getByTestSubject('flyout');
124+
const ariaLabelledBy = flyout.getAttribute('aria-labelledby');
125+
126+
// Should have a generated ID for the menu title
127+
expect(ariaLabelledBy).toBeTruthy();
128+
expect(ariaLabelledBy).toMatch(/^generated-id/);
129+
});
130+
131+
it('uses custom titleId when provided in flyoutMenuProps', () => {
132+
const customTitleId = 'my-custom-title-id';
133+
const { getByTestSubject } = render(
134+
<EuiFlyout
135+
onClose={() => {}}
136+
flyoutMenuProps={{
137+
title: 'Test Menu Title',
138+
titleId: customTitleId,
139+
}}
140+
data-test-subj="flyout"
141+
/>
142+
);
143+
144+
const flyout = getByTestSubject('flyout');
145+
expect(flyout).toHaveAttribute('aria-labelledby', customTitleId);
146+
});
147+
148+
it('combines flyout menu ID with existing aria-labelledby', () => {
149+
const customTitleId = 'my-custom-title-id';
150+
const existingAriaLabelledBy = 'existing-label-id';
151+
152+
const { getByTestSubject } = render(
153+
<EuiFlyout
154+
onClose={() => {}}
155+
flyoutMenuProps={{
156+
title: 'Test Menu Title',
157+
titleId: customTitleId,
158+
}}
159+
aria-labelledby={existingAriaLabelledBy}
160+
data-test-subj="flyout"
161+
/>
162+
);
163+
164+
const flyout = getByTestSubject('flyout');
165+
expect(flyout).toHaveAttribute(
166+
'aria-labelledby',
167+
`${customTitleId} ${existingAriaLabelledBy}`
168+
);
169+
});
170+
171+
it('does not set aria-labelledby when flyout has no menu', () => {
172+
const { getByTestSubject } = render(
173+
<EuiFlyout onClose={() => {}} data-test-subj="flyout" />
174+
);
175+
176+
const flyout = getByTestSubject('flyout');
177+
expect(flyout).not.toHaveAttribute('aria-labelledby');
178+
});
179+
180+
it('only uses existing aria-labelledby when no menu is present', () => {
181+
const existingAriaLabelledBy = 'existing-label-id';
182+
183+
const { getByTestSubject } = render(
184+
<EuiFlyout
185+
onClose={() => {}}
186+
aria-labelledby={existingAriaLabelledBy}
187+
data-test-subj="flyout"
188+
/>
189+
);
190+
191+
const flyout = getByTestSubject('flyout');
192+
expect(flyout).toHaveAttribute('aria-labelledby', existingAriaLabelledBy);
193+
});
194+
});
195+
113196
describe('props', () => {
114197
test('hideCloseButton', () => {
115198
const { baseElement } = render(

packages/eui/src/components/flyout/flyout_menu.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import React, {
1414
useState,
1515
} from 'react';
1616

17-
import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../services';
17+
import { useEuiMemoizedStyles } from '../../services';
1818
import { EuiButtonEmpty, EuiButtonIcon, EuiButtonProps } from '../button';
1919
import { CommonProps, PropsForAnchor } from '../common';
2020
import { EuiFlexGroup, EuiFlexItem } from '../flex';
@@ -39,6 +39,8 @@ type EuiFlyoutHistoryItem = {
3939

4040
export type EuiFlyoutMenuProps = CommonProps &
4141
HTMLAttributes<HTMLDivElement> & {
42+
/* An id to use for the title element */
43+
titleId?: string;
4244
title?: React.ReactNode;
4345
hideCloseButton?: boolean;
4446
showBackButton?: boolean;
@@ -102,6 +104,7 @@ const HistoryPopover: React.FC<{
102104
};
103105

104106
export const EuiFlyoutMenu: FunctionComponent<EuiFlyoutMenuProps> = ({
107+
titleId,
105108
className,
106109
title,
107110
hideCloseButton,
@@ -115,7 +118,6 @@ export const EuiFlyoutMenu: FunctionComponent<EuiFlyoutMenuProps> = ({
115118

116119
const styles = useEuiMemoizedStyles(euiFlyoutMenuStyles);
117120
const classes = classNames('euiFlyoutMenu', className);
118-
const titleId = useGeneratedHtmlId();
119121

120122
let titleNode;
121123
if (title) {

packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ const FlyoutSession: React.FC<FlyoutSessionProps> = React.memo((props) => {
101101
id={`mainFlyout-${title}`}
102102
session={true}
103103
flyoutMenuProps={{ title: `${title} - Main` }}
104-
aria-labelledby="flyoutTitle"
105104
size={mainSize}
106105
maxWidth={mainMaxWidth}
107106
type={flyoutType}

packages/eui/src/components/flyout/manager/validation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('Flyout Size Validation', () => {
6666
const error = validateFlyoutTitle('', 'test-id', 'main');
6767
expect(error).toEqual({
6868
type: 'INVALID_FLYOUT_MENU_TITLE',
69-
message: `Managed flyouts require either a 'flyoutMenuProps' a 'title' property, or an 'aria-label' to provide the title.`,
69+
message: `Managed flyouts require either a 'flyoutMenuProps.title' or an 'aria-label' to provide the flyout menu title.`,
7070
flyoutId: 'test-id',
7171
level: 'main',
7272
});

packages/eui/src/components/flyout/manager/validation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function validateFlyoutTitle(
6464
if (level === LEVEL_MAIN && !flyoutMenuTitle) {
6565
return {
6666
type: 'INVALID_FLYOUT_MENU_TITLE',
67-
message: `Managed flyouts require either a 'flyoutMenuProps' a 'title' property, or an 'aria-label' to provide the title.`,
67+
message: `Managed flyouts require either a 'flyoutMenuProps.title' or an 'aria-label' to provide the flyout menu title.`,
6868
flyoutId,
6969
level,
7070
};

0 commit comments

Comments
 (0)