A11y: Fix Navigation and Drawer dialogs missing programatic titles for screen readers (#118093)

* A11y: Fix Navigation and Share dialogs missing programatic titles for screen readers

* Fix unit test to use the new expected labelledby title

* Fix saved query tests

* update i18n

* Fix issue with saveQueries test

* update i18n

* Fix e2e tests to not use Drawer title prefix

* Revert i18n not related to the PR
This commit is contained in:
Alexa Vargas 2026-02-16 13:18:16 +01:00 committed by GitHub
parent 9be63b169b
commit 51cb7a6d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 87 additions and 19 deletions

View file

@ -152,7 +152,7 @@ test.describe('Panels test: LogsTable', { tag: ['@panels', '@logstable'] }, () =
// Assert the inspect drawer shows the correct log line body
await expect(
page.getByLabel('Drawer title Inspect value').locator('.view-lines'),
page.getByRole('dialog', { name: 'Inspect value' }).locator('.view-lines'),
'Drawer contains correct log line'
).toContainText(
`level=info ts=2026-02-06T18:42:46.211051027Z caller=poller.go:133 msg="blocklist poll complete" seconds=526`

View file

@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import { selectors } from '@grafana/e2e-selectors';
import { Drawer } from './Drawer';
describe('Drawer', () => {
// Drawer.tsx passes getContainer={'.main-view'} to RcDrawer, which tells it
// to portal its content into the element matching that CSS selector.
// In the real app, .main-view exists in the page shell. In tests, jsdom
// starts with an empty body, so we create it manually — otherwise RcDrawer
// has nowhere to render and the Drawer never appears in the DOM.
let mainView: HTMLDivElement;
beforeEach(() => {
mainView = document.createElement('div');
mainView.classList.add('main-view');
document.body.appendChild(mainView);
});
afterEach(() => {
document.body.removeChild(mainView);
});
it('renders with string title and children', () => {
render(
<Drawer title="Test Title" onClose={() => {}}>
<div>Drawer content</div>
</Drawer>
);
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Drawer content')).toBeInTheDocument();
});
it('has an accessible name from the visible heading when title is a string', () => {
render(
<Drawer title="Share" onClose={() => {}}>
<div>Content</div>
</Drawer>
);
const heading = screen.getByRole('heading', { name: 'Share' });
expect(heading).toHaveAttribute('id');
const drawer = screen.getByRole('dialog');
expect(drawer).toHaveAttribute('aria-labelledby', heading.getAttribute('id'));
// aria-label kept for e2e selector compatibility
expect(drawer).toHaveAttribute('aria-label', selectors.components.Drawer.General.title('Share'));
});
it('has an accessible name from a custom title element', () => {
render(
<Drawer title={<h3>Custom Title</h3>} onClose={() => {}}>
<div>Content</div>
</Drawer>
);
const heading = screen.getByText('Custom Title');
const titleWrapper = heading.closest('[id]');
expect(titleWrapper).toHaveAttribute('id');
const drawer = screen.getByRole('dialog');
expect(drawer).toHaveAttribute('aria-labelledby', titleWrapper?.getAttribute('id'));
// no aria-label for non-string titles
expect(drawer).not.toHaveAttribute('aria-label');
});
});

View file

@ -116,7 +116,7 @@ export function Drawer({
},
}}
aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined}
aria-labelledby={typeof title !== 'string' ? titleId : undefined}
aria-labelledby={title ? titleId : undefined}
width={''}
motion={{
motionAppear: true,
@ -149,7 +149,7 @@ export function Drawer({
</div>
{typeof title === 'string' ? (
<Stack direction="column">
<Text element="h3" truncate>
<Text element="h3" id={titleId} truncate>
{title}
</Text>
{subtitle && (

View file

@ -7,6 +7,7 @@ import CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
@ -43,7 +44,7 @@ export function AppChromeMenu({}: Props) {
},
ref
);
const { dialogProps } = useDialog({}, ref);
const { dialogProps } = useDialog({ 'aria-label': t('navigation.megamenu.dialog-label', 'Navigation') }, ref);
const styles = useStyles2(getStyles);
return (

View file

@ -26,7 +26,7 @@ const ui = {
statusReceiving: byText(/receiving grafana-managed alerts/i),
statusNotReceiving: byText(/not receiving/i),
configurationDrawer: byRole('dialog', { name: 'Drawer title Grafana built-in Alertmanager' }),
configurationDrawer: byRole('dialog', { name: 'Grafana built-in Alertmanager' }),
editConfigurationButton: byRole('button', { name: /edit configuration/i }),
viewConfigurationButton: byRole('button', { name: /view configuration/i }),
saveConfigurationButton: byRole('button', { name: /save/i }),

View file

@ -611,7 +611,7 @@ describe('contact points', () => {
await user.click(await screen.findByRole('menuitem', { name: /manage permissions/i }));
const permissionsDialog = await screen.findByRole('dialog', { name: /drawer title manage permissions/i });
const permissionsDialog = await screen.findByRole('dialog', { name: /manage permissions/i });
expect(permissionsDialog).toBeInTheDocument();
expect(await screen.findByRole('table')).toBeInTheDocument();

View file

@ -49,7 +49,7 @@ describe('MuteTimingsTable', () => {
renderWithProvider();
await user.click(await screen.findByRole('button', { name: /export all/i }));
expect(await screen.findByRole('dialog', { name: /drawer title export/i })).toBeInTheDocument();
expect(await screen.findByRole('dialog', { name: /export/i })).toBeInTheDocument();
});
it("shows individual 'export' drawer when allowed and supported, and can close", async () => {
@ -58,11 +58,11 @@ describe('MuteTimingsTable', () => {
const exportMuteTiming = await within(table).findAllByText(/export/i);
await user.click(exportMuteTiming[0]);
expect(await screen.findByRole('dialog', { name: /drawer title export/i })).toBeInTheDocument();
expect(await screen.findByRole('dialog', { name: /export/i })).toBeInTheDocument();
await user.click(screen.getByText(/cancel/i));
expect(screen.queryByRole('dialog', { name: /drawer title export/i })).not.toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /export/i })).not.toBeInTheDocument();
});
it('does not show export button when not supported', async () => {

View file

@ -239,7 +239,7 @@ describe('RuleViewer', () => {
await renderRuleViewer(mockRule, mockRuleIdentifier);
await openSilenceDrawer();
const silenceDrawer = await screen.findByRole('dialog', { name: 'Drawer title Silence alert rule' });
const silenceDrawer = await screen.findByRole('dialog', { name: 'Silence alert rule' });
expect(await within(silenceDrawer).findByLabelText(/^alert rule/i)).toHaveValue(
grafanaRulerRule.grafana_alert.title
);

View file

@ -47,7 +47,7 @@ const ui = {
exportButton: byRole('button', { name: 'Export' }),
ruleItem: byRole('treeitem'),
export: {
dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }),
dialog: byRole('dialog', { name: /Export .* rules/ }),
jsonTab: byRole('tab', { name: /JSON/ }),
yamlTab: byRole('tab', { name: /YAML/ }),
editor: byTestId('code-editor'),

View file

@ -72,7 +72,7 @@ describe('NewActionsButton', () => {
await user.click(newButton);
await user.click(screen.getByText('New folder'));
const drawer = screen.getByRole('dialog', { name: 'Drawer title New folder' });
const drawer = screen.getByRole('dialog', { name: 'New folder' });
expect(drawer).toBeInTheDocument();
expect(within(drawer).getByRole('heading', { name: 'New folder' })).toBeInTheDocument();
expect(within(drawer).getByText(`Location: ${mockParentFolder.title}`)).toBeInTheDocument();

View file

@ -119,7 +119,7 @@ describe('browse-dashboards FolderActionsButton', () => {
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: managePermissionsLabel }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Manage permissions' })).toBeInTheDocument();
});
it('clicking the "Move" option opens the move modal', async () => {

View file

@ -43,10 +43,9 @@ export const openQueryLibrary = async () => {
const button = screen.getByRole('button', { name: 'Add from saved queries' });
await userEvent.click(button);
await waitFor(async () => {
const container = screen.getByRole('dialog', {
name: /Drawer title/,
screen.getByRole('dialog', {
name: /Saved queries/,
});
within(container).getByText('Saved queries');
});
};
@ -57,7 +56,7 @@ export const addQueryHistoryToQueryLibrary = async () => {
export const submitAddToQueryLibrary = async ({ title }: { title: string }) => {
const container = screen.getByRole('dialog', {
name: /Drawer title/i,
name: /Saved queries/i,
});
const input = within(container).getByRole('textbox', { name: /title/i });

View file

@ -333,8 +333,7 @@ export const withinQueryHistory = () => {
};
export const withinQueryLibrary = () => {
const container = screen.getByRole('dialog', { name: /Drawer title/ });
within(container).getByText('Query library');
const container = screen.getByRole('dialog', { name: /Query library/ });
return within(container);
};

View file

@ -11506,6 +11506,7 @@
},
"megamenu": {
"close": "Close menu",
"dialog-label": "Navigation",
"dock": "Dock menu",
"list-label": "Navigation",
"open": "Open menu",