Skip to content
21 changes: 19 additions & 2 deletions bulma-ui/src/form/Checkboxes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ import {
import { Field } from './Field';
import { Control } from './Control';
import { FormFieldProps } from './fieldProps';
import { useAutoLabelledBy } from './useAutoLabelId';
import { Checkbox } from './Checkbox';

/**
* Props for the Checkboxes component.
*/
export interface CheckboxesProps
extends Omit<BulmaClassesProps, 'color'>, FormFieldProps {
/** Field label naming the whole group. Automatically associated via `aria-labelledby` on the `role="group"` wrapper — uses your `labelProps.id` when provided, otherwise a generated one. Dropped inside an outer `Field` (label that `Field` yourself). */
label?: React.ReactNode;
/** Props for the label element. An explicit `id` here is used as the `aria-labelledby` target instead of a generated one; any `htmlFor` is ignored (a group label names the group, never a single control). */
labelProps?: React.LabelHTMLAttributes<HTMLLabelElement> & {
[key: string]: unknown;
};
/** Additional CSS classes to apply. */
className?: string;
/** Form field name shared by every Checkbox in the group (via context). */
Expand Down Expand Up @@ -75,6 +82,11 @@ const CheckboxesComponent: React.FC<CheckboxesProps> = ({
}) => {
const insideField = useInsideField();
const insideControl = useInsideControl();
const { ariaLabelledBy, fieldLabelProps } = useAutoLabelledBy({
label,
labelProps,
rendersLabel: !insideField,
});
const { bulmaHelperClasses, rest } = useBulmaClasses({
...props,
});
Expand Down Expand Up @@ -113,7 +125,12 @@ const CheckboxesComponent: React.FC<CheckboxesProps> = ({
);

const checkboxesElement = (
<div className={wrapperClass} {...rest}>
<div
className={wrapperClass}
role="group"
aria-labelledby={ariaLabelledBy}
{...rest}
>
<CheckboxesProvider value={ctx}>{children}</CheckboxesProvider>
</div>
);
Expand All @@ -129,7 +146,7 @@ const CheckboxesComponent: React.FC<CheckboxesProps> = ({
<Field
label={label}
labelSize={labelSize}
labelProps={labelProps}
labelProps={fieldLabelProps}
horizontal={horizontal}
className={fieldClassName}
>
Expand Down
21 changes: 19 additions & 2 deletions bulma-ui/src/form/Radios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ import {
import { Field } from './Field';
import { Control } from './Control';
import { FormFieldProps } from './fieldProps';
import { useAutoLabelledBy } from './useAutoLabelId';
import { Radio } from './Radio';

/**
* Props for the Radios component.
*/
export interface RadiosProps
extends Omit<BulmaClassesProps, 'color'>, FormFieldProps {
/** Field label naming the whole group. Automatically associated via `aria-labelledby` on the `role="radiogroup"` wrapper — uses your `labelProps.id` when provided, otherwise a generated one. Dropped inside an outer `Field` (label that `Field` yourself). */
label?: React.ReactNode;
/** Props for the label element. An explicit `id` here is used as the `aria-labelledby` target instead of a generated one; any `htmlFor` is ignored (a group label names the group, never a single control). */
labelProps?: React.LabelHTMLAttributes<HTMLLabelElement> & {
[key: string]: unknown;
};
/** Additional CSS classes to apply. */
className?: string;
/** Form field name shared by every Radio in the group (via context). */
Expand Down Expand Up @@ -74,6 +81,11 @@ const RadiosComponent: React.FC<RadiosProps> = ({
}) => {
const insideField = useInsideField();
const insideControl = useInsideControl();
const { ariaLabelledBy, fieldLabelProps } = useAutoLabelledBy({
label,
labelProps,
rendersLabel: !insideField,
});
const { bulmaHelperClasses, rest } = useBulmaClasses({
...props,
});
Expand Down Expand Up @@ -115,7 +127,12 @@ const RadiosComponent: React.FC<RadiosProps> = ({
);

const radiosElement = (
<div className={wrapperClass} {...rest}>
<div
className={wrapperClass}
role="radiogroup"
aria-labelledby={ariaLabelledBy}
{...rest}
>
<RadiosProvider value={ctx}>{children}</RadiosProvider>
</div>
);
Expand All @@ -131,7 +148,7 @@ const RadiosComponent: React.FC<RadiosProps> = ({
<Field
label={label}
labelSize={labelSize}
labelProps={labelProps}
labelProps={fieldLabelProps}
horizontal={horizontal}
className={fieldClassName}
>
Expand Down
18 changes: 16 additions & 2 deletions bulma-ui/src/form/Rate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useInsideField, useInsideControl } from './FormContext';
import { Field } from './Field';
import { Control } from './Control';
import { FormFieldProps } from './fieldProps';
import { useAutoLabelledBy } from './useAutoLabelId';

/** Available size modifiers for the Rate component. */
export type RateSize = 'small' | 'medium' | 'large';
Expand Down Expand Up @@ -44,6 +45,12 @@ export interface RateProps
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'color'>,
Omit<BulmaClassesProps, 'size'>,
FormFieldProps {
/** Field label naming the rating group. Automatically associated via `aria-labelledby` on the `role="radiogroup"` container — uses your `labelProps.id` when provided, otherwise a generated one; it also replaces the default `aria-label="Rating"`. Dropped inside an outer `Field` (label that `Field` yourself). */
label?: React.ReactNode;
/** Props for the label element. An explicit `id` here is used as the `aria-labelledby` target instead of a generated one; any `htmlFor` is ignored (a group label names the group, never a single control). */
labelProps?: React.LabelHTMLAttributes<HTMLLabelElement> & {
[key: string]: unknown;
};
/** Form field name. When set, a hidden input is rendered. */
name?: string;
/** Optional id of the form this hidden input belongs to (use when the input lives outside the form element). */
Expand Down Expand Up @@ -178,6 +185,11 @@ export const Rate = forwardRef<HTMLDivElement, RateProps>(
) => {
const insideField = useInsideField();
const insideControl = useInsideControl();
const { ariaLabelledBy, fieldLabelProps } = useAutoLabelledBy({
label,
labelProps,
rendersLabel: !insideField,
});
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
const [internalValue, setInternalValue] = useState(defaultValue);
const [hoverValue, setHoverValue] = useState<number | null>(null);
Expand Down Expand Up @@ -505,7 +517,9 @@ export const Rate = forwardRef<HTMLDivElement, RateProps>(
ref={ref}
className={combinedClasses}
role="radiogroup"
aria-label="Rating"
// Fallback name only: a wired label names the group via labelledby.
aria-label={ariaLabelledBy ? undefined : 'Rating'}
aria-labelledby={ariaLabelledBy}
aria-valuenow={currentValue}
aria-valuemin={0}
aria-valuemax={max}
Expand Down Expand Up @@ -539,7 +553,7 @@ export const Rate = forwardRef<HTMLDivElement, RateProps>(
<Field
label={label}
labelSize={labelSize}
labelProps={labelProps}
labelProps={fieldLabelProps}
horizontal={horizontal}
className={fieldClassName}
>
Expand Down
80 changes: 80 additions & 0 deletions bulma-ui/src/form/__tests__/Checkboxes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,83 @@ describe('Compound components', () => {
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
});
});

describe('Checkboxes group label association (#494)', () => {
it('names the group from the label via aria-labelledby', () => {
const { container } = render(
<Checkboxes label="Chores">
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
);
const group = screen.getByRole('group', { name: 'Chores' });
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel.id).toBeTruthy();
expect(group).toHaveAttribute('aria-labelledby', fieldLabel.id);
expect(fieldLabel).not.toHaveAttribute('for');
});

it('uses a user-supplied labelProps.id as the target', () => {
const { container } = render(
<Checkboxes label="Chores" labelProps={{ id: 'chores-label' }}>
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
);
expect(container.querySelector('label.label')).toHaveAttribute(
'id',
'chores-label'
);
expect(screen.getByRole('group')).toHaveAttribute(
'aria-labelledby',
'chores-label'
);
});

it('strips a caller labelProps.htmlFor from the group label', () => {
const { container } = render(
<Checkboxes label="Chores" labelProps={{ htmlFor: 'some-control' }}>
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
);
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel).not.toHaveAttribute('for');
expect(screen.getByRole('group')).toHaveAttribute(
'aria-labelledby',
fieldLabel.id
);
});

it('renders role=group without aria-labelledby when unlabeled', () => {
render(
<Checkboxes>
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
);
expect(screen.getByRole('group')).not.toHaveAttribute('aria-labelledby');
});

it('injects nothing inside an outer Field, which drops the label', () => {
const { container } = render(
<Field label="Outer">
<Checkboxes label="Dropped">
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
</Field>
);
expect(screen.getByRole('group')).not.toHaveAttribute('aria-labelledby');
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel).toHaveTextContent('Outer');
expect(fieldLabel).not.toHaveAttribute('id');
});

it('lets a user aria-labelledby in rest win', () => {
render(
<Checkboxes label="Chores" aria-labelledby="mine">
<Checkbox value="bed">Make the bed</Checkbox>
</Checkboxes>
);
expect(screen.getByRole('group')).toHaveAttribute(
'aria-labelledby',
'mine'
);
});
});
81 changes: 81 additions & 0 deletions bulma-ui/src/form/__tests__/Radios.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,84 @@ describe('Compound components', () => {
expect(screen.getAllByRole('radio')).toHaveLength(2);
});
});

describe('Radios group label association (#494)', () => {
it('names the radiogroup from the label via aria-labelledby', () => {
const { container } = render(
<Radios label="Color" name="color">
<Radio value="red">Red</Radio>
</Radios>
);
const group = screen.getByRole('radiogroup', { name: 'Color' });
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel.id).toBeTruthy();
expect(group).toHaveAttribute('aria-labelledby', fieldLabel.id);
expect(fieldLabel).not.toHaveAttribute('for');
});

it('uses a user-supplied labelProps.id as the target', () => {
const { container } = render(
<Radios label="Color" labelProps={{ id: 'color-label' }} name="color">
<Radio value="red">Red</Radio>
</Radios>
);
expect(container.querySelector('label.label')).toHaveAttribute(
'id',
'color-label'
);
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-labelledby',
'color-label'
);
});

it('strips a caller labelProps.htmlFor from the group label', () => {
const { container } = render(
<Radios
label="Color"
labelProps={{ htmlFor: 'some-control' }}
name="color"
>
<Radio value="red">Red</Radio>
</Radios>
);
expect(container.querySelector('label.label')).not.toHaveAttribute('for');
});

it('names the radiogroup in horizontal layout', () => {
const { container } = render(
<Radios label="Color" name="color" horizontal>
<Radio value="red">Red</Radio>
</Radios>
);
const group = screen.getByRole('radiogroup', { name: 'Color' });
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(group).toHaveAttribute('aria-labelledby', fieldLabel.id);
});

it('renders role=radiogroup without aria-labelledby when unlabeled', () => {
render(
<Radios name="color">
<Radio value="red">Red</Radio>
</Radios>
);
expect(screen.getByRole('radiogroup')).not.toHaveAttribute(
'aria-labelledby'
);
});

it('injects nothing inside an outer Field, which drops the label', () => {
const { container } = render(
<Field label="Outer">
<Radios label="Dropped" name="color">
<Radio value="red">Red</Radio>
</Radios>
</Field>
);
expect(screen.getByRole('radiogroup')).not.toHaveAttribute(
'aria-labelledby'
);
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel).not.toHaveAttribute('id');
});
});
46 changes: 46 additions & 0 deletions bulma-ui/src/form/__tests__/Rate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -952,3 +952,49 @@ describe('Rate', () => {
});
});
});

describe('Rate group label association (#494)', () => {
it('names the radiogroup from the label and drops the fallback aria-label', () => {
const { container } = render(<Rate label="Quality" />);
const group = screen.getByRole('radiogroup', { name: 'Quality' });
const fieldLabel = container.querySelector('label.label') as HTMLElement;
expect(fieldLabel.id).toBeTruthy();
expect(group).toHaveAttribute('aria-labelledby', fieldLabel.id);
expect(group).not.toHaveAttribute('aria-label');
expect(fieldLabel).not.toHaveAttribute('for');
});

it('uses a user-supplied labelProps.id as the target', () => {
render(<Rate label="Quality" labelProps={{ id: 'quality-label' }} />);
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-labelledby',
'quality-label'
);
});

it('strips a caller labelProps.htmlFor from the group label', () => {
const { container } = render(
<Rate label="Quality" labelProps={{ htmlFor: 'some-control' }} />
);
expect(container.querySelector('label.label')).not.toHaveAttribute('for');
});

it('keeps the "Rating" fallback aria-label when unlabeled', () => {
render(<Rate />);
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-label',
'Rating'
);
});

it('keeps the fallback inside an outer Field, which drops the label', () => {
render(
<Field label="Outer">
<Rate label="Dropped" />
</Field>
);
const group = screen.getByRole('radiogroup');
expect(group).toHaveAttribute('aria-label', 'Rating');
expect(group).not.toHaveAttribute('aria-labelledby');
});
});
Loading
Loading