diff --git a/bulma-ui/src/form/Checkboxes.tsx b/bulma-ui/src/form/Checkboxes.tsx index 197c7c8b7..13aab190e 100644 --- a/bulma-ui/src/form/Checkboxes.tsx +++ b/bulma-ui/src/form/Checkboxes.tsx @@ -11,6 +11,7 @@ import { import { Field } from './Field'; import { Control } from './Control'; import { FormFieldProps } from './fieldProps'; +import { useAutoLabelledBy } from './useAutoLabelId'; import { Checkbox } from './Checkbox'; /** @@ -18,6 +19,12 @@ import { Checkbox } from './Checkbox'; */ export interface CheckboxesProps extends Omit, 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 & { + [key: string]: unknown; + }; /** Additional CSS classes to apply. */ className?: string; /** Form field name shared by every Checkbox in the group (via context). */ @@ -75,6 +82,11 @@ const CheckboxesComponent: React.FC = ({ }) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { ariaLabelledBy, fieldLabelProps } = useAutoLabelledBy({ + label, + labelProps, + rendersLabel: !insideField, + }); const { bulmaHelperClasses, rest } = useBulmaClasses({ ...props, }); @@ -113,7 +125,12 @@ const CheckboxesComponent: React.FC = ({ ); const checkboxesElement = ( -
+
{children}
); @@ -129,7 +146,7 @@ const CheckboxesComponent: React.FC = ({ diff --git a/bulma-ui/src/form/Radios.tsx b/bulma-ui/src/form/Radios.tsx index 1b67dc7c7..00289ce15 100644 --- a/bulma-ui/src/form/Radios.tsx +++ b/bulma-ui/src/form/Radios.tsx @@ -11,6 +11,7 @@ import { import { Field } from './Field'; import { Control } from './Control'; import { FormFieldProps } from './fieldProps'; +import { useAutoLabelledBy } from './useAutoLabelId'; import { Radio } from './Radio'; /** @@ -18,6 +19,12 @@ import { Radio } from './Radio'; */ export interface RadiosProps extends Omit, 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 & { + [key: string]: unknown; + }; /** Additional CSS classes to apply. */ className?: string; /** Form field name shared by every Radio in the group (via context). */ @@ -74,6 +81,11 @@ const RadiosComponent: React.FC = ({ }) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { ariaLabelledBy, fieldLabelProps } = useAutoLabelledBy({ + label, + labelProps, + rendersLabel: !insideField, + }); const { bulmaHelperClasses, rest } = useBulmaClasses({ ...props, }); @@ -115,7 +127,12 @@ const RadiosComponent: React.FC = ({ ); const radiosElement = ( -
+
{children}
); @@ -131,7 +148,7 @@ const RadiosComponent: React.FC = ({ diff --git a/bulma-ui/src/form/Rate.tsx b/bulma-ui/src/form/Rate.tsx index 177a4f96d..59bf2d59e 100644 --- a/bulma-ui/src/form/Rate.tsx +++ b/bulma-ui/src/form/Rate.tsx @@ -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'; @@ -44,6 +45,12 @@ export interface RateProps Omit, 'onChange' | 'color'>, Omit, 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 & { + [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). */ @@ -178,6 +185,11 @@ export const Rate = forwardRef( ) => { 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(null); @@ -505,7 +517,9 @@ export const Rate = forwardRef( 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} @@ -539,7 +553,7 @@ export const Rate = forwardRef( diff --git a/bulma-ui/src/form/__tests__/Checkboxes.test.tsx b/bulma-ui/src/form/__tests__/Checkboxes.test.tsx index 62f72a0fb..082a2c36b 100644 --- a/bulma-ui/src/form/__tests__/Checkboxes.test.tsx +++ b/bulma-ui/src/form/__tests__/Checkboxes.test.tsx @@ -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( + + Make the bed + + ); + 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( + + Make the bed + + ); + 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( + + Make the bed + + ); + 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( + + Make the bed + + ); + expect(screen.getByRole('group')).not.toHaveAttribute('aria-labelledby'); + }); + + it('injects nothing inside an outer Field, which drops the label', () => { + const { container } = render( + + + Make the bed + + + ); + 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( + + Make the bed + + ); + expect(screen.getByRole('group')).toHaveAttribute( + 'aria-labelledby', + 'mine' + ); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Radios.test.tsx b/bulma-ui/src/form/__tests__/Radios.test.tsx index d5f249d6e..704e25ec1 100644 --- a/bulma-ui/src/form/__tests__/Radios.test.tsx +++ b/bulma-ui/src/form/__tests__/Radios.test.tsx @@ -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( + + Red + + ); + 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( + + Red + + ); + 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( + + Red + + ); + expect(container.querySelector('label.label')).not.toHaveAttribute('for'); + }); + + it('names the radiogroup in horizontal layout', () => { + const { container } = render( + + Red + + ); + 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( + + Red + + ); + expect(screen.getByRole('radiogroup')).not.toHaveAttribute( + 'aria-labelledby' + ); + }); + + it('injects nothing inside an outer Field, which drops the label', () => { + const { container } = render( + + + Red + + + ); + expect(screen.getByRole('radiogroup')).not.toHaveAttribute( + 'aria-labelledby' + ); + const fieldLabel = container.querySelector('label.label') as HTMLElement; + expect(fieldLabel).not.toHaveAttribute('id'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Rate.test.tsx b/bulma-ui/src/form/__tests__/Rate.test.tsx index c8b5b35ec..107d59006 100644 --- a/bulma-ui/src/form/__tests__/Rate.test.tsx +++ b/bulma-ui/src/form/__tests__/Rate.test.tsx @@ -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(); + 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(); + expect(screen.getByRole('radiogroup')).toHaveAttribute( + 'aria-labelledby', + 'quality-label' + ); + }); + + it('strips a caller labelProps.htmlFor from the group label', () => { + const { container } = render( + + ); + expect(container.querySelector('label.label')).not.toHaveAttribute('for'); + }); + + it('keeps the "Rating" fallback aria-label when unlabeled', () => { + render(); + expect(screen.getByRole('radiogroup')).toHaveAttribute( + 'aria-label', + 'Rating' + ); + }); + + it('keeps the fallback inside an outer Field, which drops the label', () => { + render( + + + + ); + const group = screen.getByRole('radiogroup'); + expect(group).toHaveAttribute('aria-label', 'Rating'); + expect(group).not.toHaveAttribute('aria-labelledby'); + }); +}); diff --git a/bulma-ui/src/form/fieldProps.ts b/bulma-ui/src/form/fieldProps.ts index c0b08b72d..052413d70 100644 --- a/bulma-ui/src/form/fieldProps.ts +++ b/bulma-ui/src/form/fieldProps.ts @@ -5,11 +5,11 @@ import React from 'react'; * When these props are provided and the component is not already inside a Field, * it will automatically render a Field around itself. */ -// NOTE: the label/labelProps TSDoc here must stay neutral about htmlFor -// association — this interface is also inherited by group inputs -// (Checkboxes, Radios, Rate) that do NOT auto-wire it. -// Single-control components that do (via useAutoLabelId, #368/#493) -// shadow-redeclare these members with the stronger claim. +// NOTE: the label/labelProps TSDoc here stays neutral about association +// mechanics — every inheritor auto-associates, but by different means: +// single-control inputs wire htmlFor/id (useAutoLabelId, #368/#493), group +// inputs wire aria-labelledby (useAutoLabelledBy, #494). Each component +// shadow-redeclares these members with its specific claim. export interface FormFieldProps { /** Field label, rendered above the widget. */ label?: React.ReactNode; diff --git a/bulma-ui/src/form/useAutoLabelId.ts b/bulma-ui/src/form/useAutoLabelId.ts index 7615426d8..a8b658d36 100644 --- a/bulma-ui/src/form/useAutoLabelId.ts +++ b/bulma-ui/src/form/useAutoLabelId.ts @@ -43,3 +43,40 @@ export function useAutoLabelId({ : labelProps; return { controlId, fieldLabelProps }; } + +interface UseAutoLabelledByOptions { + /** The convenience `label` prop as passed by the caller. */ + label: React.ReactNode; + /** User-supplied labelProps, if any. */ + labelProps?: FieldProps['labelProps']; + /** True when this render actually outputs the label naming the group. */ + rendersLabel: boolean; +} + +/** + * Group-input counterpart of {@link useAutoLabelId} (#494): a group of + * controls cannot take a single `htmlFor`, so instead the rendered `