mirror of
https://github.com/grafana/grafana.git
synced 2026-02-18 18:20:52 -05:00
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:
parent
9be63b169b
commit
51cb7a6d20
14 changed files with 87 additions and 19 deletions
|
|
@ -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`
|
||||
|
|
|
|||
68
packages/grafana-ui/src/components/Drawer/Drawer.test.tsx
Normal file
68
packages/grafana-ui/src/components/Drawer/Drawer.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11506,6 +11506,7 @@
|
|||
},
|
||||
"megamenu": {
|
||||
"close": "Close menu",
|
||||
"dialog-label": "Navigation",
|
||||
"dock": "Dock menu",
|
||||
"list-label": "Navigation",
|
||||
"open": "Open menu",
|
||||
|
|
|
|||
Loading…
Reference in a new issue