+
{title}
diff --git a/superset-frontend/src/components/Modal/ModalFormField.tsx b/superset-frontend/src/components/Modal/ModalFormField.tsx
index c825e465b58c..e658a5c4f900 100644
--- a/superset-frontend/src/components/Modal/ModalFormField.tsx
+++ b/superset-frontend/src/components/Modal/ModalFormField.tsx
@@ -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';
@@ -128,6 +128,42 @@ 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): React.ReactElement => {
+ 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,
+ ariaProps,
+ ),
+ });
+ }
+ return cloneElement(element, ariaProps);
+ };
+
+ const enhancedChildren = error
+ ? Children.map(children, (child, index) => {
+ if (index === 0 && isValidElement(child)) {
+ return applyAria(child as React.ReactElement);
+ }
+ return child;
+ })
+ : children;
+
return (
@@ -135,9 +171,13 @@ export function ModalFormField({
{tooltip && }
{required && *}
- {children}
+ {enhancedChildren}
{helperText && {helperText}
}
- {error && {error}
}
+ {error && (
+
+ {error}
+
+ )}
);
}
diff --git a/superset-frontend/src/explore/components/ControlHeader.tsx b/superset-frontend/src/explore/components/ControlHeader.tsx
index a2bbf047c31d..f269fa259b3d 100644
--- a/superset-frontend/src/explore/components/ControlHeader.tsx
+++ b/superset-frontend/src/explore/components/ControlHeader.tsx
@@ -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';
@@ -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;
};
@@ -70,8 +74,11 @@ const ControlHeader: FC = ({
tooltipOnClick = () => {},
warning,
danger,
+ errorId: providedErrorId,
}) => {
const theme = useTheme();
+ const uniqueId = useId();
+ const errorId = providedErrorId ?? `${name || uniqueId}-error`;
if (!label) {
return null;
@@ -178,8 +185,28 @@ const ControlHeader: FC = ({
placement="top"
title={validationErrors?.join(' ')}
>
-
- {' '}
+
+
+
+ {validationErrors?.join('. ')}
+ {' '}
)}
{renderOptionalIcons()}
diff --git a/superset-frontend/src/explore/components/controls/NumberControl/index.tsx b/superset-frontend/src/explore/components/controls/NumberControl/index.tsx
index db7d85b97a03..8cb0bac1c1a7 100644
--- a/superset-frontend/src/explore/components/controls/NumberControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/NumberControl/index.tsx
@@ -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';
@@ -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;
@@ -62,6 +69,8 @@ export default function NumberControl({
...rest
}: NumberControlProps) {
const pendingValueRef = useRef(value);
+ const uniqueId = useId();
+ const inputId = rest.name || uniqueId;
const handleChange = (val: string | number | null) => {
pendingValueRef.current = parseValue(val);
@@ -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 (
-
+
+ {hasErrors && (
+
+ {rest.validationErrors!.join('. ')}
+
+ )}
);
}
diff --git a/superset-frontend/src/explore/components/controls/TextControl/index.tsx b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
index 805d3d90eb79..d2d9d0e6be3d 100644
--- a/superset-frontend/src/explore/components/controls/TextControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
@@ -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 {
@@ -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 (
-
+
+ {hasErrors && (
+
+ {this.props.validationErrors!.join('. ')}
+
+ )}
);
}
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index ee300a5380b4..fc8d5f026f63 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -1746,11 +1746,18 @@ const AlertReportModal: FunctionComponent = ({
};
const validateGeneralSection = () => {
+ // Skip validation while alert data is still loading so the error display
+ // (which is also gated on `currentAlert`) and the disable-save flag stay
+ // consistent. Once the modal initializes `currentAlert`, validation reruns.
+ if (!currentAlert) {
+ updateValidationStatus(Sections.General, []);
+ return;
+ }
const errors = [];
- if (!currentAlert?.name?.length) {
+ if (!currentAlert.name?.length) {
errors.push(TRANSLATIONS.NAME_ERROR_TEXT);
}
- if (!currentAlert?.owners?.length) {
+ if (!currentAlert.owners?.length) {
errors.push(TRANSLATIONS.OWNERS_ERROR_TEXT);
}
updateValidationStatus(Sections.General, errors);
@@ -2125,6 +2132,15 @@ const AlertReportModal: FunctionComponent = ({
= ({
onChange={onInputChange}
/>
-
+
) => void;
+ 'aria-invalid'?: boolean;
+ 'aria-describedby'?: string;
}
export default function NumberInput({
diff --git a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx
index ed47ed1952e9..b7fe3024bcc9 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/SSHTunnelForm.tsx
@@ -18,7 +18,7 @@
*/
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
-import { styled } from '@apache-superset/core/theme';
+import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import {
Form,
FormLabel,
@@ -52,6 +52,13 @@ const StyledInputPassword = styled(Input.Password)`
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
`;
+const fieldErrorStyles = (theme: SupersetTheme) => css`
+ color: ${theme.colorError};
+ font-size: ${theme.fontSizeSM}px;
+ display: block;
+ margin-top: ${theme.sizeUnit}px;
+`;
+
const SSHTunnelForm = ({
db,
onSSHTunnelParametersChange,
@@ -62,6 +69,15 @@ const SSHTunnelForm = ({
setSSHTunnelLoginMethod: (method: AuthType) => void;
}) => {
const [usePassword, setUsePassword] = useState(AuthType.Password);
+ const [blurred, setBlurred] = useState>({});
+ const markBlurred = (field: string) =>
+ setBlurred(prev => ({ ...prev, [field]: true }));
+ const fieldError = (field: string, value: string | number | undefined) =>
+ // WCAG 3.3.1: treat whitespace-only strings as empty so users cannot
+ // bypass required-field validation with a space character.
+ blurred[field] &&
+ (value === undefined ||
+ (typeof value === 'string' && value.trim().length === 0));
return (
+ {nameError && (
+
+ {t('Display Name is required')}
+
+ )}
{t('Pick a name to help you identify this database.')}
@@ -82,8 +94,16 @@ const SqlAlchemyTab = ({
t('dialect+driver://username:password@host:port/database')
}
onChange={onInputChange}
+ onBlur={() => setUriBlurred(true)}
+ aria-invalid={uriError || undefined}
+ aria-describedby={uriError ? 'sqlalchemy-uri-error' : undefined}
/>
+ {uriError && (
+