diff --git a/bulma-ui/src/form/DateInput.tsx b/bulma-ui/src/form/DateInput.tsx index 2d5c29b8b..e0faf4fc5 100644 --- a/bulma-ui/src/form/DateInput.tsx +++ b/bulma-ui/src/form/DateInput.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { DateInputBase, DateInputBaseProps } from './DateInputBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the DateInput convenience wrapper. Extends `DateInputBaseProps` @@ -15,11 +16,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * @extraProp {React.Ref} [ref] - Forwarded to the underlying ``. */ export interface DateInputProps extends DateInputBaseProps { - /** Field label (component auto-wraps in a `Field` if not already inside). */ + /** Field label (component auto-wraps in a `Field` if not already inside). Automatically associated with the input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Not wired in `inline` mode (no visible input to label) and dropped inside an outer `Field`. */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Render the field with horizontal layout. */ horizontal?: boolean; @@ -98,6 +99,13 @@ export const DateInput = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: baseProps.id, + labelProps, + // Inline mode renders a bare calendar with no input to label. + rendersLabel: !insideField && !baseProps.inline, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); @@ -107,6 +115,7 @@ export const DateInput = forwardRef( let content: React.ReactNode = ( @@ -142,7 +151,7 @@ export const DateInput = forwardRef( diff --git a/bulma-ui/src/form/DateTimeInput.tsx b/bulma-ui/src/form/DateTimeInput.tsx index 5fb88b649..bd615edfc 100644 --- a/bulma-ui/src/form/DateTimeInput.tsx +++ b/bulma-ui/src/form/DateTimeInput.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { DateTimeInputBase, DateTimeInputBaseProps } from './DateTimeInputBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the DateTimeInput convenience wrapper. Extends @@ -13,11 +14,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * @extraProp {boolean} [required=false] - Marks the field as required for native HTML form validation. */ export interface DateTimeInputProps extends DateTimeInputBaseProps { - /** Field label. */ + /** Field label. Automatically associated with the input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Not wired in `inline` mode (no visible input to label) and dropped inside an outer `Field`. */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Render the field with horizontal layout. */ horizontal?: boolean; @@ -96,6 +97,13 @@ export const DateTimeInput = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: baseProps.id, + labelProps, + // Inline mode renders a bare picker with no input to label. + rendersLabel: !insideField && !baseProps.inline, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); @@ -105,6 +113,7 @@ export const DateTimeInput = forwardRef( let content: React.ReactNode = ( @@ -140,7 +149,7 @@ export const DateTimeInput = forwardRef( diff --git a/bulma-ui/src/form/Field.tsx b/bulma-ui/src/form/Field.tsx index eb6dd0fc7..6e60b7c56 100644 --- a/bulma-ui/src/form/Field.tsx +++ b/bulma-ui/src/form/Field.tsx @@ -24,11 +24,11 @@ export interface FieldProps hasAddons?: boolean | 'centered' | 'right'; /** Constrains the field to its content's width (used inside horizontal field bodies). */ narrow?: boolean; - /** Field label, rendered above the widget. */ + /** Field label, rendered above the widget. `Field` itself does not associate it with any control — pass `labelProps={{ htmlFor }}` and a matching `id` on your control (the single-control convenience inputs do this automatically when they render their own `Field`). */ label?: React.ReactNode; /** Size for the label. */ labelSize?: 'small' | 'normal' | 'medium' | 'large'; - /** Props for the label element. */ + /** Props for the label element — where `htmlFor` goes when composing. */ labelProps?: React.LabelHTMLAttributes & { [key: string]: unknown; }; diff --git a/bulma-ui/src/form/File.tsx b/bulma-ui/src/form/File.tsx index 4079e5e1e..eb96ed589 100644 --- a/bulma-ui/src/form/File.tsx +++ b/bulma-ui/src/form/File.tsx @@ -9,6 +9,7 @@ import { useConfig } from '../helpers/Config'; import { useInsideField } from './FormContext'; import { Field } from './Field'; import { FormFieldProps } from './fieldProps'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the File component. @@ -21,6 +22,12 @@ export interface FileProps >, Omit, FormFieldProps { + /** Field label. Automatically associated with the file input via `htmlFor` — uses your `id` when provided, otherwise a generated one. The input then has two labels (this one plus the wrapping `file-label`); assistive tech reads both. Dropped inside an outer `Field` (label that `Field` yourself). */ + label?: React.ReactNode; + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ + labelProps?: React.LabelHTMLAttributes & { + [key: string]: unknown; + }; /** Bulma color modifier for the file input. */ color?: | 'primary' @@ -96,6 +103,12 @@ export const File = forwardRef( ref ) => { const insideField = useInsideField(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: props.id, + labelProps, + rendersLabel: !insideField, + }); const { classPrefix } = useConfig(); const { bulmaHelperClasses, rest } = useBulmaClasses({ color, @@ -142,6 +155,7 @@ export const File = forwardRef( inputClassName )} type="file" + id={controlId} {...rest} /> @@ -173,7 +187,7 @@ export const File = forwardRef( diff --git a/bulma-ui/src/form/Input.tsx b/bulma-ui/src/form/Input.tsx index d412ba913..057241b50 100644 --- a/bulma-ui/src/form/Input.tsx +++ b/bulma-ui/src/form/Input.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { InputBase, InputBaseProps } from './InputBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the Input component. @@ -13,11 +14,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * Control-level (icons, loading) props. */ export interface InputProps extends InputBaseProps { - /** Field label. */ + /** Field label. Automatically associated with the input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field` (label that `Field` yourself). */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Horizontal field layout. */ horizontal?: boolean; @@ -105,11 +106,17 @@ export const Input = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: inputProps.id, + labelProps, + rendersLabel: !insideField, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); - let content = ; + let content = ; if (!insideControl) { content = ( @@ -139,7 +146,7 @@ export const Input = forwardRef( diff --git a/bulma-ui/src/form/Numberinput.tsx b/bulma-ui/src/form/Numberinput.tsx index e37816238..0fd2850d8 100644 --- a/bulma-ui/src/form/Numberinput.tsx +++ b/bulma-ui/src/form/Numberinput.tsx @@ -5,6 +5,7 @@ import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; import { useInsideField } from './FormContext'; import { Field } from './Field'; import { FormFieldProps } from './fieldProps'; +import { useAutoLabelId } from './useAutoLabelId'; /** Valid sizes for the Numberinput component. */ export type NumberinputSize = 'small' | 'medium' | 'large'; @@ -43,6 +44,12 @@ export interface NumberinputProps >, Omit, FormFieldProps { + /** Field label. Automatically associated with the number input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Not wired when no Field wrapper is rendered (inside a `Field`, or plusminus `bare`). */ + label?: React.ReactNode; + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ + labelProps?: React.LabelHTMLAttributes & { + [key: string]: unknown; + }; /** Controlled value. */ value?: number; /** Default value for uncontrolled usage. */ @@ -175,6 +182,14 @@ export const Numberinput = forwardRef( const isAddons = compact || isStepper; const effectiveControlsPosition = isStepper ? 'right' : controlsPosition; + // The plusminus bare branch renders no Field and no label at all. + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: props.id, + labelProps, + rendersLabel: !insideField && (isStepper || !effectiveBare), + }); + // Clamp value to min/max const clampValue = useCallback( (val: number): number => { @@ -324,6 +339,7 @@ export const Numberinput = forwardRef( aria-valuenow={currentValue ?? undefined} aria-valuemin={min} aria-valuemax={max} + id={controlId} {...rest} /> @@ -366,7 +382,7 @@ export const Numberinput = forwardRef( @@ -494,7 +510,7 @@ export const Numberinput = forwardRef( diff --git a/bulma-ui/src/form/Select.tsx b/bulma-ui/src/form/Select.tsx index bbbad0983..d5e712fcb 100644 --- a/bulma-ui/src/form/Select.tsx +++ b/bulma-ui/src/form/Select.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { SelectBase, SelectBaseProps } from './SelectBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the Select component. @@ -13,11 +14,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * Control-level (icons, loading) props. */ export interface SelectProps extends SelectBaseProps { - /** Field label. */ + /** Field label. Automatically associated with the select via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field` (label that `Field` yourself). */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Horizontal field layout. */ horizontal?: boolean; @@ -89,11 +90,17 @@ export const Select = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: selectProps.id, + labelProps, + rendersLabel: !insideField, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); - let content = ; + let content = ; if (!insideControl) { content = ( @@ -118,7 +125,7 @@ export const Select = forwardRef( diff --git a/bulma-ui/src/form/Slider.tsx b/bulma-ui/src/form/Slider.tsx index 584292725..79c297769 100644 --- a/bulma-ui/src/form/Slider.tsx +++ b/bulma-ui/src/form/Slider.tsx @@ -17,6 +17,7 @@ import { useInsideField, useInsideControl } from './FormContext'; import { Field } from './Field'; import { Control } from './Control'; import { FormFieldProps } from './fieldProps'; +import { useAutoLabelId } from './useAutoLabelId'; /** Valid sizes for the Slider component. */ export type SliderSize = 'small' | 'medium' | 'large'; @@ -52,6 +53,12 @@ interface SliderBaseProps >, Omit, FormFieldProps { + /** Field label. Automatically associated with the slider input via `htmlFor` — uses your `id` when provided, otherwise a generated one. In range mode the label targets the low (minimum) thumb. Dropped inside an outer `Field` (label that `Field` yourself). */ + label?: React.ReactNode; + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ + labelProps?: React.LabelHTMLAttributes & { + [key: string]: unknown; + }; /** Minimum value. */ min?: number; /** Maximum value. */ @@ -293,6 +300,14 @@ export const Slider = forwardRef( const insideField = useInsideField(); const insideControl = useInsideControl(); const { bulmaHelperClasses, rest } = useBulmaClasses(restProps); + // In range mode the low thumb carries `rest` (and any user id), so the + // label targets it; each thumb keeps its own aria-label for AT. + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: restProps.id, + labelProps, + rendersLabel: !insideField, + }); // Resolve tooltip mode: explicit tooltip prop takes precedence, else showOutput maps to 'auto' const tooltipMode: SliderTooltip = @@ -661,6 +676,7 @@ export const Slider = forwardRef( aria-orientation={isVertical ? 'vertical' : undefined} aria-label={ariaLabel as string | undefined} {...getAriaProps(currentSingle)} + id={controlId} {...rest} /> {tooltipMode !== 'hidden' && ( @@ -742,6 +758,7 @@ export const Slider = forwardRef( (ariaLabel as [string, string] | undefined)?.[0] ?? 'Minimum value' } {...getAriaProps(currentRange[0])} + id={controlId} {...rest} {...(nameLow !== undefined ? { name: nameLow } : {})} /> @@ -863,7 +880,7 @@ export const Slider = forwardRef( diff --git a/bulma-ui/src/form/TextArea.tsx b/bulma-ui/src/form/TextArea.tsx index a53d02acb..abe3e902d 100644 --- a/bulma-ui/src/form/TextArea.tsx +++ b/bulma-ui/src/form/TextArea.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { TextAreaBase, TextAreaBaseProps } from './TextAreaBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the TextArea component. @@ -13,11 +14,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * Control-level (loading) props. */ export interface TextAreaProps extends TextAreaBaseProps { - /** Field label. */ + /** Field label. Automatically associated with the textarea via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field` (label that `Field` yourself). */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Horizontal field layout. */ horizontal?: boolean; @@ -76,11 +77,17 @@ export const TextArea = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: textAreaProps.id, + labelProps, + rendersLabel: !insideField, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); - let content = ; + let content = ; if (!insideControl) { content = ( @@ -101,7 +108,7 @@ export const TextArea = forwardRef( diff --git a/bulma-ui/src/form/TimeInput.tsx b/bulma-ui/src/form/TimeInput.tsx index 775e7a0e7..b818f211a 100644 --- a/bulma-ui/src/form/TimeInput.tsx +++ b/bulma-ui/src/form/TimeInput.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps } from './Field'; import { Control, ControlBaseProps } from './Control'; import { TimeInputBase, TimeInputBaseProps } from './TimeInputBase'; import { useInsideField, useInsideControl } from './FormContext'; +import { useAutoLabelId } from './useAutoLabelId'; /** * Props for the TimeInput convenience wrapper. Extends `TimeInputBaseProps` @@ -15,11 +16,11 @@ import { useInsideField, useInsideControl } from './FormContext'; * @extraProp {React.Ref} [ref] - Forwarded to the underlying ``. */ export interface TimeInputProps extends TimeInputBaseProps { - /** Field label. */ + /** Field label. Automatically associated with the input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Not wired in `inline` mode (no visible input to label) and dropped inside an outer `Field`. */ label?: React.ReactNode; /** Size for the label. */ labelSize?: FieldProps['labelSize']; - /** Props for the label element. */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ labelProps?: FieldProps['labelProps']; /** Render the field with horizontal layout. */ horizontal?: boolean; @@ -94,6 +95,13 @@ export const TimeInput = forwardRef( ) => { const insideField = useInsideField(); const insideControl = useInsideControl(); + const { controlId, fieldLabelProps } = useAutoLabelId({ + label, + id: baseProps.id, + labelProps, + // Inline mode renders a bare panel with no input to label. + rendersLabel: !insideField && !baseProps.inline, + }); const helpClass = usePrefixedClassNames('help', { [`is-${messageColor}`]: !!messageColor, }); @@ -103,6 +111,7 @@ export const TimeInput = forwardRef( let content: React.ReactNode = ( @@ -138,7 +147,7 @@ export const TimeInput = forwardRef( diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx index 29aa1a55d..a5759207b 100644 --- a/bulma-ui/src/form/__tests__/DateInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx @@ -1030,3 +1030,40 @@ describe('DateInputBase remaining branches', () => { expect(container.querySelector('input[type="hidden"]')).toBeNull(); }); }); + +describe('DateInput label association (#368)', () => { + it('associates the label with the input via htmlFor/id', () => { + const { getByRole, container } = render(); + const input = getByRole('combobox'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('respects a user id and still derives the popover id from it', () => { + const { getByRole, container } = render( + + ); + const input = getByRole('combobox'); + expect(input).toHaveAttribute('id', 'when'); + expect(input).toHaveAttribute('aria-controls', 'when-popover'); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + 'when' + ); + }); + + it('injects no id without a label', () => { + const { getByRole } = render(); + expect(getByRole('combobox')).not.toHaveAttribute('id'); + }); + + it('renders the label unwired in inline mode', () => { + const { container } = render(); + const label = container.querySelector('label.label'); + expect(label).toHaveTextContent('Date'); + expect(label).not.toHaveAttribute('for'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx index 008730c6f..1fdb35d16 100644 --- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx @@ -1142,3 +1142,29 @@ describe('DateTimeInput small viewport', () => { expect(option.style.height).toBe('40px'); }); }); + +describe('DateTimeInput label association (#368)', () => { + it('associates the label with the input via htmlFor/id', () => { + const { getByRole, container } = render( + + ); + const input = getByRole('combobox'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('injects no id without a label', () => { + const { getByRole } = render(); + expect(getByRole('combobox')).not.toHaveAttribute('id'); + }); + + it('renders the label unwired in inline mode', () => { + const { container } = render(); + const label = container.querySelector('label.label'); + expect(label).toHaveTextContent('Appointment'); + expect(label).not.toHaveAttribute('for'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/File.test.tsx b/bulma-ui/src/form/__tests__/File.test.tsx index 92604559a..8cb2119d4 100644 --- a/bulma-ui/src/form/__tests__/File.test.tsx +++ b/bulma-ui/src/form/__tests__/File.test.tsx @@ -174,3 +174,28 @@ describe('File', () => { }); }); }); + +describe('File label association (#368)', () => { + it('associates the Field label with the file input', () => { + const { container } = render(); + const input = screen.getByLabelText('Avatar'); + expect(input).toHaveAttribute('type', 'file'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('resolves the Field label and the CTA label to the same input', () => { + render(); + expect(screen.getByLabelText('Avatar')).toBe( + screen.getByLabelText(/choose a file/i) + ); + }); + + it('injects no id without a label', () => { + const { container } = render(); + expect(container.querySelector('input')).not.toHaveAttribute('id'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Input.test.tsx b/bulma-ui/src/form/__tests__/Input.test.tsx index 3c0fa367f..9511ff026 100644 --- a/bulma-ui/src/form/__tests__/Input.test.tsx +++ b/bulma-ui/src/form/__tests__/Input.test.tsx @@ -2,6 +2,7 @@ import React, { createRef } from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Input } from '../Input'; +import { Field } from '../Field'; import { ConfigProvider } from '../../helpers/Config'; describe('Input', () => { @@ -184,3 +185,72 @@ describe('Input', () => { }); }); }); + +describe('Input label association (#368)', () => { + it('associates the label with the input via htmlFor/id', () => { + const { container } = render(); + const input = screen.getByLabelText('Username'); + expect(input.tagName).toBe('INPUT'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('uses a user-supplied id as the association target', () => { + const { container } = render(); + const input = screen.getByLabelText('Username'); + expect(input).toHaveAttribute('id', 'my-input'); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + 'my-input' + ); + }); + + it('lets an explicit labelProps.htmlFor override the association', () => { + const { container } = render( + + ); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + 'other' + ); + expect(container.querySelector('input')).toHaveAttribute('id', 'my-input'); + }); + + it('injects no id when labelProps.htmlFor is set without an id', () => { + const { container } = render( + + ); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + 'elsewhere' + ); + expect(container.querySelector('input')).not.toHaveAttribute('id'); + }); + + it('injects no id without a label', () => { + const { container } = render(); + expect(container.querySelector('input')).not.toHaveAttribute('id'); + }); + + it('injects no id inside an outer Field, which drops the label', () => { + const { container } = render( + + + + ); + expect(container.querySelectorAll('label').length).toBe(1); + expect(container.querySelector('input')).not.toHaveAttribute('id'); + }); + + it('associates in horizontal layout', () => { + const { container } = render(); + const input = screen.getByLabelText('Username'); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Numberinput.test.tsx b/bulma-ui/src/form/__tests__/Numberinput.test.tsx index 8769466e1..88e394b1f 100644 --- a/bulma-ui/src/form/__tests__/Numberinput.test.tsx +++ b/bulma-ui/src/form/__tests__/Numberinput.test.tsx @@ -942,3 +942,55 @@ describe('Numberinput', () => { }); }); }); + +describe('Numberinput label association (#368)', () => { + it('associates the label with the number input (plusminus)', () => { + const { container } = render(); + const input = screen.getByLabelText('Quantity'); + expect(input).toHaveAttribute('type', 'number'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('associates the label in the stepper variant', () => { + const { container } = render( + + ); + const input = screen.getByLabelText('Quantity'); + expect(input).toHaveAttribute('type', 'number'); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('renders no label and injects no id in bare mode', () => { + const { container } = render(); + expect(container.querySelector('label')).not.toBeInTheDocument(); + expect(container.querySelector('input[type="number"]')).not.toHaveAttribute( + 'id' + ); + }); + + it('injects no id without a label', () => { + const { container } = render(); + expect(container.querySelector('input[type="number"]')).not.toHaveAttribute( + 'id' + ); + }); + + it('injects no id inside an outer Field, which drops the label', () => { + const { container } = render( + + + + ); + expect(container.querySelectorAll('label').length).toBe(1); + expect(container.querySelector('input[type="number"]')).not.toHaveAttribute( + 'id' + ); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Select.test.tsx b/bulma-ui/src/form/__tests__/Select.test.tsx index 592bed272..67c554034 100644 --- a/bulma-ui/src/form/__tests__/Select.test.tsx +++ b/bulma-ui/src/form/__tests__/Select.test.tsx @@ -200,3 +200,29 @@ describe('Select', () => { }); }); }); + +describe('Select label association (#368)', () => { + it('associates the label with the select via htmlFor/id', () => { + const { container } = render( + + ); + const select = screen.getByLabelText('Country'); + expect(select.tagName).toBe('SELECT'); + expect(select.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + select.id + ); + }); + + it('injects no id without a label', () => { + const { container } = render( + + ); + expect(container.querySelector('select')).not.toHaveAttribute('id'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/Slider.test.tsx b/bulma-ui/src/form/__tests__/Slider.test.tsx index 802c27f00..a5ebea19b 100644 --- a/bulma-ui/src/form/__tests__/Slider.test.tsx +++ b/bulma-ui/src/form/__tests__/Slider.test.tsx @@ -1263,3 +1263,33 @@ describe('Slider', () => { }); }); }); + +describe('Slider label association (#368)', () => { + it('associates the label with the slider input', () => { + const { container } = render(); + const input = screen.getByLabelText('Volume'); + expect(input).toHaveAttribute('type', 'range'); + expect(input.id).toBeTruthy(); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + input.id + ); + }); + + it('targets the low thumb in range mode', () => { + const { container } = render(); + const low = container.querySelector('.slider-input-low') as HTMLElement; + const high = container.querySelector('.slider-input-high') as HTMLElement; + expect(low.id).toBeTruthy(); + expect(high).not.toHaveAttribute('id'); + expect(container.querySelector('label.label')).toHaveAttribute( + 'for', + low.id + ); + }); + + it('injects no id without a label', () => { + const { container } = render(); + expect(container.querySelector('input')).not.toHaveAttribute('id'); + }); +}); diff --git a/bulma-ui/src/form/__tests__/TextArea.test.tsx b/bulma-ui/src/form/__tests__/TextArea.test.tsx index 2b36bf53b..ee8f2d3a4 100644 --- a/bulma-ui/src/form/__tests__/TextArea.test.tsx +++ b/bulma-ui/src/form/__tests__/TextArea.test.tsx @@ -200,3 +200,21 @@ describe('TextArea', () => { }); }); }); + +describe('TextArea label association (#368)', () => { + it('associates the label with the textarea via htmlFor/id', () => { + const { container } = render(