Compare commits

...

5 Commits

Author SHA1 Message Date
Elizabeth Thompson
74ab9a1256 test(dashboard): add regression coverage for icon-only toolbar accessible names
Covers the three call sites where hideVisibleLabel keeps the accessible
name while suppressing visible text: DeleteComponentButton, Row's
settings IconButton, and Column's settings IconButton.
2026-07-27 17:58:57 +00:00
Elizabeth Thompson
f63142e5e2 fix(a11y): suppress visible text for icon-only toolbar buttons
label was added to the Delete/Column settings/Row settings IconButtons
for their accessible name, but IconButton renders any truthy label as
visible text via StyledSpan, so these compact toolbar icons started
showing "Delete component"/"Column settings"/"Row settings" text next
to the icon.

Adds an opt-in hideVisibleLabel prop that keeps aria-label but skips
the visible span, applied only at the three new call sites. The
DashboardBuilder "Collapse tab content" menu item, which intentionally
shows visible label text, is untouched.

Per richardfogaca/rusackas review feedback (rebenitez1802's original
finding).
2026-07-26 18:25:55 +00:00
Elizabeth Thompson
2821c5760b test(dashboard): update Header delete-button query for new accessible name
DeleteComponentButton now labels its IconButton 'Delete component', so the
role/name query for the old icon-derived name 'delete' no longer matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 00:41:58 +00:00
Elizabeth Thompson
055c9dc136 Merge branch 'master' into fix/dashboard-icon-button-aria-label 2026-07-22 00:37:59 +00:00
Elizabeth Thompson
4f0434ba1d fix(a11y): add aria-label to dashboard IconButton and unlabeled call sites
Icon-only buttons (delete, row settings, column settings) had no
accessible name, making them invisible to screen readers despite being
keyboard-focusable via tabIndex=0. Wires the existing label prop through
as aria-label on the underlying div[role=button] and provides labels at
all three call sites that previously passed none.

WCAG 2.1 SC 4.1.2 (Name, Role, Value) — Level A.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 00:44:35 +00:00
9 changed files with 85 additions and 3 deletions

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import DeleteComponentButton from './DeleteComponentButton';
test('exposes an accessible name without rendering visible label text', () => {
render(<DeleteComponentButton onDelete={jest.fn()} />);
expect(
screen.getByRole('button', { name: 'Delete component' }),
).toBeInTheDocument();
expect(screen.queryByText('Delete component')).not.toBeInTheDocument();
});
test('calls onDelete when clicked', () => {
const onDelete = jest.fn();
render(<DeleteComponentButton onDelete={onDelete} />);
fireEvent.click(screen.getByRole('button', { name: 'Delete component' }));
expect(onDelete).toHaveBeenCalledTimes(1);
});

View File

@@ -18,6 +18,7 @@
*/
import { MouseEventHandler, FC } from 'react';
import { t } from '@apache-superset/core/translation';
import { Icons } from '@superset-ui/core/components/Icons';
import type { IconType } from '@superset-ui/core/components/Icons/types';
import IconButton from './IconButton';
@@ -33,6 +34,8 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
}) => (
<IconButton
onClick={onDelete}
label={t('Delete component')}
hideVisibleLabel
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
/>
);

View File

@@ -73,3 +73,17 @@ test('renders the provided label', () => {
expect(screen.getByText('My Label')).toBeInTheDocument();
});
test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => {
render(
<IconButton
icon={icon}
onClick={jest.fn()}
label="My Label"
hideVisibleLabel
/>,
);
expect(screen.queryByText('My Label')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument();
});

View File

@@ -22,6 +22,7 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
interface IconButtonProps extends HTMLAttributes<HTMLDivElement> {
icon: JSX.Element;
label?: string;
hideVisibleLabel?: boolean;
onClick: MouseEventHandler<HTMLDivElement>;
disabled?: boolean;
'data-test'?: string;
@@ -59,6 +60,7 @@ const IconButton = forwardRef<HTMLDivElement, IconButtonProps>(
{
icon,
label,
hideVisibleLabel,
onClick,
onKeyDown,
disabled,
@@ -72,6 +74,7 @@ const IconButton = forwardRef<HTMLDivElement, IconButtonProps>(
ref={ref}
tabIndex={disabled ? -1 : 0}
role="button"
aria-label={label}
isDisabled={disabled}
aria-disabled={disabled}
data-test={dataTest}
@@ -88,7 +91,7 @@ const IconButton = forwardRef<HTMLDivElement, IconButtonProps>(
}}
>
{icon}
{label && <StyledSpan>{label}</StyledSpan>}
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
</StyledDiv>
),
);

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
@@ -200,6 +200,15 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: columnWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Column settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Column settings')).not.toBeInTheDocument();
});
test('should pass its own width as availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => {
/>
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Column settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>

View File

@@ -145,7 +145,9 @@ describe('Header', () => {
const deleteComponent = jest.fn();
setup({ editMode: true, deleteComponent });
const trashButton = screen.getByRole('button', { name: 'delete' });
const trashButton = screen.getByRole('button', {
name: 'Delete component',
});
fireEvent.click(trashButton);
expect(deleteComponent).toHaveBeenCalledTimes(1);

View File

@@ -240,6 +240,15 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: rowWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Row settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Row settings')).not.toBeInTheDocument();
});
test('should pass appropriate availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => {
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Row settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>