Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,44 @@
* under the License.
*/

import { render } from '../../testing';
import { render, screen } from '../../testing';
import { Alert } from '.';

test('renders Alert with default props', async () => {
const { container } = render(<Alert />);
expect(container).toHaveTextContent('Default message');
});

// WCAG 3.3.1 - Error Identification accessibility tests
test('renders with role="alert" for screen reader announcement', () => {
render(<Alert type="error">Error message</Alert>);
expect(screen.getByRole('alert')).toBeInTheDocument();
});

test('renders with aria-atomic="true" so full content is announced', () => {
render(<Alert type="error">Error message</Alert>);
expect(screen.getByRole('alert')).toHaveAttribute('aria-atomic', 'true');
});

test('uses aria-live="assertive" for error alerts', () => {
render(<Alert type="error">Error message</Alert>);
expect(screen.getByRole('alert')).toHaveAttribute(
'aria-live',
'assertive',
);
});

test('uses aria-live="polite" for warning alerts', () => {
render(<Alert type="warning">Warning message</Alert>);
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'polite');
});

test('uses aria-live="polite" for info alerts', () => {
render(<Alert type="info">Info message</Alert>);
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'polite');
});

test('uses aria-live="polite" for success alerts', () => {
render(<Alert type="success">Success message</Alert>);
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'polite');
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const Alert = (props: AlertProps) => {
return (
<AntdAlert
role="alert"
aria-atomic="true"
aria-live={type === 'error' ? 'assertive' : 'polite'}
type={type}
showIcon={showIcon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const LabeledErrorBoundInput = ({
...props
}: LabeledErrorBoundInputProps) => {
const hasError = !!errorMessage;
const errorDescriptionId = hasError ? `${id || props.name}-error-description` : undefined;
return (
<StyledFormGroup className={className}>
<Flex align="center">
Expand All @@ -78,13 +79,23 @@ export const LabeledErrorBoundInput = ({
validateStatus={
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
help={
hasError ? (
<span id={errorDescriptionId} role="alert">
{errorMessage}
</span>
) : (
helpText
)
}
hasFeedback={!!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword
{...props}
{...validationMethods}
aria-invalid={hasError || undefined}
aria-describedby={errorDescriptionId}
iconRender={visible =>
visible ? (
<Tooltip title={t('Hide password.')}>
Expand All @@ -99,7 +110,12 @@ export const LabeledErrorBoundInput = ({
role="textbox"
/>
) : (
<StyledInput {...props} {...validationMethods} />
<StyledInput
{...props}
{...validationMethods}
aria-invalid={hasError || undefined}
aria-describedby={errorDescriptionId}
/>
)}
{get_url && description ? (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,23 @@ test('should render with error theme', () => {
`,
);
});

// WCAG 3.3.1 - Error Identification accessibility tests
test('should have role="alert" for screen reader announcement', () => {
render(<BasicErrorAlert {...mockedProps} />);
const alertElement = screen.getByRole('alert');
expect(alertElement).toBeInTheDocument();
});

test('should have aria-atomic="true" so the entire alert is read as a unit', () => {
render(<BasicErrorAlert {...mockedProps} />);
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveAttribute('aria-atomic', 'true');
});

test('should contain both title and body text within the alert region', () => {
render(<BasicErrorAlert {...mockedProps} />);
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveTextContent('Error title');
expect(alertElement).toHaveTextContent('Error body');
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function BasicErrorAlert({
};

return (
<div style={style} role="alert">
<div style={style} role="alert" aria-atomic="true">
<Icons.ExclamationCircleFilled iconColor={variants.text} />
<StyledContent>
<StyledTitle>{title}</StyledTitle>
Expand Down
46 changes: 43 additions & 3 deletions superset-frontend/src/components/Modal/ModalFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { ReactNode, Children, isValidElement, cloneElement, useId } from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { InfoTooltip } from '@superset-ui/core/components';

Expand Down Expand Up @@ -128,16 +128,56 @@ export function ModalFormField({
validateStatus,
hasFeedback = false,
}: ModalFormFieldProps) {
const uniqueId = useId();
const errorId = error ? `${uniqueId}-error` : undefined;

// Clone the first child element to inject aria-invalid and aria-describedby
// on the real input. Many call sites wrap the input in a FormItem/Row so
// the ARIA attrs must hop through wrappers to reach the actual interactive
// element, otherwise screen readers never learn about the error.
const applyAria = (element: React.ReactElement<any>): React.ReactElement<any> => {
const ariaProps = {
'aria-invalid': true,
'aria-describedby': errorId,
};
const wrappedChild = element.props?.children;
if (
isValidElement(wrappedChild) &&
Children.count(wrappedChild) === 1
) {
return cloneElement(element, {
children: cloneElement(
wrappedChild as React.ReactElement<any>,
ariaProps,
),
});
}
return cloneElement(element, ariaProps);
};

const enhancedChildren = error
? Children.map(children, (child, index) => {
if (index === 0 && isValidElement(child)) {
return applyAria(child as React.ReactElement<any>);
}
return child;
Comment on lines +160 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The accessibility attributes are currently injected only into the first direct child, but many call sites pass wrappers like FormItem as that child, so the real input never receives aria-invalid/aria-describedby. This causes error association to fail at runtime for wrapped fields. Apply the attributes to the wrapped control element when the first child is a wrapper with a single valid child. [logic error]

Severity Level: Critical 🚨
- ⚠️ Dashboard name field lacks ARIA linkage to error.
- ⚠️ Screen readers may not announce dashboard name validation errors.
- ⚠️ WCAG 3.3.1 coverage incomplete for wrapped dashboard inputs.
Suggested change
if (index === 0 && isValidElement(child)) {
return cloneElement(child as React.ReactElement<any>, {
'aria-invalid': true,
'aria-describedby': errorId,
});
}
return child;
if (index !== 0 || !isValidElement(child)) {
return child;
}
const ariaProps = {
'aria-invalid': true,
'aria-describedby': errorId,
};
const wrappedChild = (child as React.ReactElement<any>).props?.children;
if (isValidElement(wrappedChild)) {
return cloneElement(child as React.ReactElement<any>, {
children: cloneElement(wrappedChild as React.ReactElement<any>, ariaProps),
});
}
return cloneElement(child as React.ReactElement<any>, ariaProps);
Steps of Reproduction ✅
1. Open any dashboard in the Superset UI and launch the Dashboard Properties modal
(component `PropertiesModal` at
`superset-frontend/src/dashboard/components/PropertiesModal/index.tsx:16-27`), e.g., via
the "Edit dashboard properties" action in the dashboard header.

2. In the "General information" section of this modal (rendered by `Collapse` items at
`index.tsx:8-23` which include `<BasicInfoSection ... />` at `index.tsx:19-22`), locate
the "Name" field implemented in `BasicInfoSection`
(`superset-frontend/src/dashboard/components/PropertiesModal/sections/BasicInfoSection.tsx:40-63`).
This field wraps the actual input inside a `FormItem`:

   - `<ModalFormField ... error={hasError ? t('Dashboard name is required') : undefined}>`
   (`BasicInfoSection.tsx:40-45`)

   - First child: `<FormItem ...><Input ... /></FormItem>` (`BasicInfoSection.tsx:46-62`).

3. With the modal open, clear the "Name" input so it is empty, then trigger validation
(e.g., click the Save/Apply button so `validationStatus.basic?.hasErrors` becomes true and
`error` is passed into `ModalFormField` as shown above). At this point `ModalFormField`
(`superset-frontend/src/components/Modal/ModalFormField.tsx:119-145`) executes:

   - Computes `errorId` and then `enhancedChildren = error ? Children.map(children, ...) :
   children;` (`ModalFormField.tsx:131-135`).

   - For the first child (the `FormItem` wrapper), the `if (index === 0 &&
   isValidElement(child))` branch returns `cloneElement(child, { 'aria-invalid': true,
   'aria-describedby': errorId })` (`ModalFormField.tsx:137-141`), so the ARIA attributes
   are attached to the `FormItem` React element, not its `<Input>` child.

4. Inspect the rendered DOM for the "Name" input in browser devtools: the `<input>`
element rendered by `Input` (child of `FormItem`) does not have `aria-invalid="true"` or
`aria-describedby="<errorId>"`, while those attributes are applied to the DOM node
rendered for `FormItem` (a wrapper element). As a result, the error message container
`<div class="error" id={errorId} role="alert">` (`ModalFormField.tsx:156-159`) is not
referenced by the actual `<input>` via `aria-describedby`, so assistive technologies may
not correctly associate the error with the input, even though the same `ModalFormField`
behaves correctly for direct children like `<Input />` in other call sites (e.g.,
`AlertReportModal` at `superset-frontend/src/features/alerts/AlertReportModal.tsx:20-42,
2100-2219` where no `FormItem` wrapper is used).
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/components/Modal/ModalFormField.tsx
**Line:** 137:143
**Comment:**
	*Logic Error: The accessibility attributes are currently injected only into the first direct child, but many call sites pass wrappers like `FormItem` as that child, so the real input never receives `aria-invalid`/`aria-describedby`. This causes error association to fail at runtime for wrapped fields. Apply the attributes to the wrapped control element when the first child is a wrapper with a single valid child.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

})
: children;

return (
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
<div className="control-label">
{label}
{tooltip && <InfoTooltip tooltip={tooltip} />}
{required && <span className="required">*</span>}
</div>
<div className="input-container">{children}</div>
<div className="input-container">{enhancedChildren}</div>
{helperText && <div className="helper">{helperText}</div>}
{error && <div className="error">{error}</div>}
{error && (
<div className="error" id={errorId} role="alert">
{error}
</div>
)}
</StyledFieldContainer>
);
}
33 changes: 30 additions & 3 deletions superset-frontend/src/explore/components/ControlHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, ReactNode } from 'react';
import { FC, ReactNode, useId } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, useTheme, SupersetTheme } from '@apache-superset/core/theme';
import { FormLabel, InfoTooltip, Tooltip } from '@superset-ui/core/components';
Expand All @@ -37,6 +37,10 @@ export type ControlHeaderProps = {
tooltipOnClick?: () => void;
warning?: string;
danger?: string;
// WCAG 3.3.1: error container ID shared with the input's aria-describedby.
// Passed in by wrappers like NumberControl/TextControl so there is exactly
// one live-region and one DOM id per control (no duplicate ids/alerts).
errorId?: string;
// Allow extra props from control spread patterns (e.g. {...this.props})
[key: string]: unknown;
};
Expand Down Expand Up @@ -70,8 +74,11 @@ const ControlHeader: FC<ControlHeaderProps> = ({
tooltipOnClick = () => {},
warning,
danger,
errorId: providedErrorId,
}) => {
const theme = useTheme();
const uniqueId = useId();
const errorId = providedErrorId ?? `${name || uniqueId}-error`;

if (!label) {
return null;
Expand Down Expand Up @@ -178,8 +185,28 @@ const ControlHeader: FC<ControlHeaderProps> = ({
placement="top"
title={validationErrors?.join(' ')}
>
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
</Tooltip>{' '}
<Icons.ExclamationCircleOutlined
iconColor={theme.colorError}
aria-hidden="true"
/>
</Tooltip>
<span
id={errorId}
role="alert"
css={css`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`}
>
{validationErrors?.join('. ')}
</span>{' '}
Comment on lines +193 to +209
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate HTML IDs in Error Handling

The added screen reader span improves accessibility by announcing validation errors, but the id attribute creates duplicate HTML ids when used with controls like NumberControl that already define an error span with the same id. This violates HTML uniqueness rules and could confuse assistive technologies. Removing the id maintains the alert functionality while fixing the conflict.

Code suggestion
Check the AI-generated fix before applying
 -              <span
 -                id={`${name || 'control'}-error`}
 -                role="alert"
 -                css={css`
 -                  position: absolute;
 -                  width: 1px;
 -                  height: 1px;
 -                  padding: 0;
 -                  margin: -1px;
 -                  overflow: hidden;
 -                  clip: rect(0, 0, 0, 0);
 -                  white-space: nowrap;
 -                  border: 0;
 -                `}
 -              >
 -                {validationErrors?.join('. ')}
 -              </span>{' '}
 +              <span
 +                role="alert"
 +                css={css`
 +                  position: absolute;
 +                  width: 1px;
 -                  height: 1px;
 -                  padding: 0;
 -                  margin: -1px;
 -                  overflow: hidden;
 -                  clip: rect(0, 0, 0, 0);
 -                  white-space: nowrap;
 -                  border: 0;
 -                `}
 -              >
 -                {validationErrors?.join('. ')}
 -              </span>{' '}'

Code Review Run #f7d2d1


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

</span>
)}
{renderOptionalIcons()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef } from 'react';
import { styled } from '@apache-superset/core/theme';
import { useId, useRef } from 'react';
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import { InputNumber } from '@superset-ui/core/components/Input';
import ControlHeader, { ControlHeaderProps } from '../../ControlHeader';

Expand All @@ -43,6 +43,13 @@ const FullWidthInputNumber = styled(InputNumber)`
width: 100%;
`;

const fieldErrorStyles = (theme: SupersetTheme) => css`
color: ${theme.colorError};
font-size: ${theme.fontSizeSM}px;
display: block;
margin-top: ${theme.sizeUnit}px;
`;

function parseValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return undefined;
Expand All @@ -62,6 +69,8 @@ export default function NumberControl({
...rest
}: NumberControlProps) {
const pendingValueRef = useRef<NumberValueType>(value);
const uniqueId = useId();
const inputId = rest.name || uniqueId;

const handleChange = (val: string | number | null) => {
pendingValueRef.current = parseValue(val);
Expand All @@ -76,9 +85,17 @@ export default function NumberControl({
onChange?.(val);
};

const hasErrors =
rest.validationErrors && rest.validationErrors.length > 0;
// WCAG 3.3.1: the visually hidden live-region inside ControlHeader carries
// the id and role="alert"; this wrapper only needs to point the input at
// that same id via aria-describedby. Sharing one id per control avoids
// duplicate DOM ids and duplicate screen-reader announcements.
const errorId = hasErrors ? `${inputId}-error` : undefined;

return (
<FullWidthDiv>
<ControlHeader {...rest} />
<ControlHeader {...rest} errorId={errorId} />
<FullWidthInputNumber
min={min}
max={max}
Expand All @@ -90,7 +107,15 @@ export default function NumberControl({
onStep={handleStep}
disabled={disabled}
aria-label={rest.label}
aria-invalid={hasErrors || undefined}
aria-describedby={errorId}
id={inputId}
/>
{hasErrors && (
<span css={fieldErrorStyles}>
{rest.validationErrors!.join('. ')}
</span>
)}
</FullWidthDiv>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@
*/
import { Component, ChangeEvent } from 'react';
import { legacyValidateNumber, legacyValidateInteger } from '@superset-ui/core';
import { css, SupersetTheme } from '@apache-superset/core/theme';
import { debounce } from 'lodash';
import ControlHeader from 'src/explore/components/ControlHeader';
import { Constants, Input } from '@superset-ui/core/components';

const fieldErrorStyles = (theme: SupersetTheme) => css`
color: ${theme.colorError};
font-size: ${theme.fontSizeSM}px;
display: block;
margin-top: ${theme.sizeUnit}px;
`;

type InputValueType = string | number;

export interface TextControlProps<T extends InputValueType = InputValueType> {
Expand Down Expand Up @@ -104,9 +112,15 @@ export default class TextControl<
this.initialValue = this.props.value;
value = safeStringify(this.props.value);
}
const hasErrors =
this.props.validationErrors && this.props.validationErrors.length > 0;
const inputId = this.props.controlId || this.props.name;
// WCAG 3.3.1: share a single error container id with ControlHeader so
// the input's aria-describedby resolves to one live-region, not two.
const errorId = hasErrors && inputId ? `${inputId}-error` : undefined;
return (
<div>
<ControlHeader {...this.props} />
<ControlHeader {...this.props} errorId={errorId} />
Comment on lines +117 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate error display

The ControlHeader component displays validation errors when the validationErrors prop is passed, but the new inline span also shows the same error text, resulting in duplicate error messages for users. To resolve this and ensure proper accessibility, omit validationErrors from ControlHeader props and add id and role attributes to the inline error span so it serves as the single error container referenced by aria-describedby.

Code suggestion
Check the AI-generated fix before applying
 -    let { value } = this.state;
 +    let { value } = this.state;
 +    const { validationErrors, ...otherProps } = this.props;
 @@ -115,1 +116,1 @@
 -        <ControlHeader {...this.props} errorId={errorId} />
 +        <ControlHeader {...otherProps} errorId={errorId} />
 @@ -129,1 +130,1 @@
 -          <span
 +          <span id={errorId} role="alert"
              style={{ color: 'red', fontSize: '12px', display: 'block', marginTop: '4px' }}

Code Review Run #38f541


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

<Input
type="text"
data-test="inline-name"
Expand All @@ -116,7 +130,15 @@ export default class TextControl<
value={value}
disabled={this.props.disabled}
aria-label={this.props.label}
aria-invalid={hasErrors || undefined}
aria-describedby={errorId}
id={inputId}
/>
{hasErrors && (
<span css={fieldErrorStyles}>
{this.props.validationErrors!.join('. ')}
</span>
)}
</div>
);
}
Expand Down
Loading
Loading