fix(ui): wire up aria-describedby for form field descriptions

Form field descriptions were visually rendered but not connected
to inputs via aria-describedby, making them invisible to screen
readers.

Added a descriptionSlot prop to FieldLabel that renders the
description as a React Aria <Text slot="description"> element,
letting React Aria automatically wire up aria-describedby on
the associated input.

Applied to TextField, PasswordField, SearchField, Select,
RadioGroup, and CheckboxGroup. Slider already handled this
manually and is unchanged.

Also added a missing WithDescription story for RadioGroup.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-04-08 16:38:50 +02:00
parent 0dc2cf7e43
commit 17eb8e03e0
12 changed files with 44 additions and 3 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Fixed form field descriptions not being connected to inputs via `aria-describedby`, making them accessible to screen readers. Added a `descriptionSlot` prop to `FieldLabel` that uses React Aria's slot mechanism to automatically wire up the connection.
**Affected components:** FieldLabel, TextField, PasswordField, SearchField, Select, RadioGroup, CheckboxGroup
+2
View File
@@ -1243,6 +1243,7 @@ export const FieldLabelDefinition: {
readonly htmlFor: {};
readonly id: {};
readonly descriptionId: {};
readonly descriptionSlot: {};
readonly className: {};
};
};
@@ -1255,6 +1256,7 @@ export type FieldLabelOwnProps = {
htmlFor?: string;
id?: string;
descriptionId?: string;
descriptionSlot?: string;
className?: string;
};
@@ -68,6 +68,7 @@ export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<div className={classes.content}>{children}</div>
<FieldError />
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Label } from 'react-aria-components';
import { Label, Text } from 'react-aria-components';
import { forwardRef } from 'react';
import type { FieldLabelProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
@@ -35,6 +35,7 @@ export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
htmlFor,
id,
descriptionId,
descriptionSlot,
} = ownProps;
if (!label) return null;
@@ -52,9 +53,14 @@ export const FieldLabel = forwardRef<HTMLDivElement, FieldLabelProps>(
</Label>
)}
{description && (
<div className={classes.description} id={descriptionId}>
<Text
slot={descriptionSlot}
className={classes.description}
elementType="div"
id={descriptionId}
>
{description}
</div>
</Text>
)}
</div>
);
@@ -37,6 +37,7 @@ export const FieldLabelDefinition = defineComponent<FieldLabelOwnProps>()({
htmlFor: {},
id: {},
descriptionId: {},
descriptionSlot: {},
className: {},
},
});
@@ -46,6 +46,11 @@ export type FieldLabelOwnProps = {
*/
descriptionId?: string;
/**
* The slot name to set on the description's React Aria `<Text>` element.
*/
descriptionSlot?: string;
className?: string;
};
@@ -69,6 +69,7 @@ export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<div
className={classes.inputWrapper}
@@ -34,6 +34,20 @@ export const Default = meta.story({
),
});
export const WithDescription = meta.story({
args: {
...Default.input.args,
description: 'Choose only one option',
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
});
export const Horizontal = meta.story({
args: {
...Default.input.args,
@@ -64,6 +64,7 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<div className={classes.content}>{children}</div>
<FieldError />
@@ -96,6 +96,7 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<div
className={classes.inputWrapper}
@@ -80,6 +80,7 @@ export const Select = forwardRef<
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<SelectTrigger icon={icon} />
<FieldError />
@@ -59,6 +59,7 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<div
className={classes.inputWrapper}