diff --git a/src/tedi/components/form/number-field/number-field.spec.tsx b/src/tedi/components/form/number-field/number-field.spec.tsx index 3b94cc09b..73ef6198a 100644 --- a/src/tedi/components/form/number-field/number-field.spec.tsx +++ b/src/tedi/components/form/number-field/number-field.spec.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { useBreakpointProps } from '../../../helpers'; +import { LabelProvider } from '../../../providers/label-provider'; import NumberField, { NumberFieldProps } from './number-field'; import '@testing-library/jest-dom'; @@ -192,4 +193,70 @@ describe('NumberField component', () => { const input = screen.getByRole('spinbutton'); expect(input).toHaveAttribute('inputmode', 'decimal'); }); + + it('treats a supplied value as controlled even without onChange (display + aria stay in sync)', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveValue('7'); + expect(input).toHaveAttribute('aria-valuenow', '7'); + }); + + describe('accessibility', () => { + it('associates the label with the spinbutton even without an explicit id', () => { + render(); + expect(screen.getByRole('spinbutton')).toHaveAccessibleName('Amount'); + }); + + it('gives each field a unique id so labels never cross-associate', () => { + render( + <> + + + + ); + + const [first, second] = screen.getAllByRole('spinbutton'); + expect(first.id).not.toBe(second.id); + expect(first).toHaveAccessibleName('First'); + expect(second).toHaveAccessibleName('Second'); + }); + + it('names the +/- buttons with the field context', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Decrease Amount by 2' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Increase Amount by 2' })).toBeInTheDocument(); + }); + + it('announces value changes driven by an external/controlled update', () => { + jest.useFakeTimers(); + try { + const { rerender } = render( + + + + ); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent(''); + + rerender( + + + + ); + act(() => { + jest.advanceTimersByTime(150); + }); + + // Assert the user-facing localized announcement, not the fallback key. + expect(status).toHaveTextContent('Updated. New value 2'); + } finally { + jest.useRealTimers(); + } + }); + }); }); diff --git a/src/tedi/components/form/number-field/number-field.stories.tsx b/src/tedi/components/form/number-field/number-field.stories.tsx index 856978270..bd87d3b1a 100644 --- a/src/tedi/components/form/number-field/number-field.stories.tsx +++ b/src/tedi/components/form/number-field/number-field.stories.tsx @@ -55,7 +55,6 @@ const TemplateSizes: StoryFn = (args) => { export const Default: Story = { args: { - id: 'example-1', label: 'Label', defaultValue: 1, step: 1, @@ -140,10 +139,8 @@ export const States: Story = { export const WithHint: Story = { args: { - id: 'example-1', label: 'Label', helper: { - id: 'example-3', text: 'Hint text', type: 'hint', }, @@ -151,7 +148,6 @@ export const WithHint: Story = { }; export const Decimal: Story = { args: { - id: 'example-1', label: 'Label', defaultValue: 1.5, step: 0.25, @@ -162,7 +158,6 @@ export const Decimal: Story = { export const WithUnit: Story = { args: { - id: 'example-2', label: 'Label', defaultValue: 2, step: 1, @@ -174,7 +169,6 @@ export const WithUnit: Story = { export const FullWidth: Story = { args: { - id: 'example-3', label: 'Label', value: 2, step: 1, diff --git a/src/tedi/components/form/number-field/number-field.tsx b/src/tedi/components/form/number-field/number-field.tsx index d85420705..c5e4cdfb0 100644 --- a/src/tedi/components/form/number-field/number-field.tsx +++ b/src/tedi/components/form/number-field/number-field.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import React, { ChangeEvent, useCallback, useEffect, useId, useRef, useState } from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -117,6 +117,9 @@ export const NumberField = (props: NumberFieldProps) => { input, } = getCurrentBreakpointProps(props); + const generatedId = useId(); + const resolvedId = id ?? generatedId; + const resolvedInputMode = inputMode ?? ((decimalPlaces && decimalPlaces > 0) || decimalSeparator === ',' ? 'decimal' : 'numeric'); @@ -131,11 +134,16 @@ export const NumberField = (props: NumberFieldProps) => { const inputRef = useRef(null); const isFocusedRef = useRef(false); - const [inputUpdated, setInputUpdated] = useState(''); + const [announcement, setAnnouncement] = useState(''); const [inputInnerValue, setInputInnerValue] = useState(defaultValue); const [displayValue, setDisplayValue] = useState(() => formatNumber(value ?? defaultValue)); - const currentValue: number | undefined = onChange && typeof value !== 'undefined' ? value : inputInnerValue; + const isControlled = value !== undefined; + const currentValue: number | undefined = isControlled ? value : inputInnerValue; + + const announceShowTimer = useRef>(); + const announceHideTimer = useRef>(); + const prevValueRef = useRef(currentValue); useEffect(() => { if (!isFocusedRef.current && typeof value !== 'undefined') { @@ -143,7 +151,7 @@ export const NumberField = (props: NumberFieldProps) => { } }, [value, formatNumber]); - const helperId = helper ? `${id}-helper` : undefined; + const helperId = helper ? `${resolvedId}-helper` : undefined; const isInvalid = useCallback( (currentValue: number | undefined): boolean => { @@ -161,14 +169,38 @@ export const NumberField = (props: NumberFieldProps) => { return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, currentValue)); }; - const updateValueUpdatedLabel = (newValue: number) => { - const valueUpdated = getLabel('numberField.quantityUpdated', newValue); + // Announce into a persistent live region. Reset to '' first, then set the + // message on the next tick so rapid or identical consecutive values are still + // re-announced (screen readers ignore a live region that keeps the same text). + const announce = useCallback((message: string) => { + if (!message) return; + setAnnouncement(''); + if (announceShowTimer.current) clearTimeout(announceShowTimer.current); + if (announceHideTimer.current) clearTimeout(announceHideTimer.current); + announceShowTimer.current = setTimeout(() => { + setAnnouncement(message); + announceHideTimer.current = setTimeout(() => setAnnouncement(''), 5000); + }, 100); + }, []); - setInputUpdated(valueUpdated); - setTimeout(() => { - setInputUpdated(''); - }, 5000); - }; + useEffect(() => { + return () => { + if (announceShowTimer.current) clearTimeout(announceShowTimer.current); + if (announceHideTimer.current) clearTimeout(announceHideTimer.current); + }; + }, []); + + // Announce every value change consistently — from the +/- buttons *and* from + // controlled/external updates. Stay silent while the user types, since the + // screen reader already echoes keystrokes in the focused field. + useEffect(() => { + if (prevValueRef.current !== currentValue) { + if (!isFocusedRef.current && currentValue !== undefined) { + announce(getLabel('numberField.quantityUpdated', currentValue)); + } + prevValueRef.current = currentValue; + } + }, [currentValue, announce, getLabel]); const roundValue = (currentValue: number) => { return decimalPlaces !== undefined ? parseFloat(currentValue.toFixed(decimalPlaces)) : currentValue; @@ -187,9 +219,8 @@ export const NumberField = (props: NumberFieldProps) => { returnValue = forceToLimits(returnValue); returnValue = roundValue(returnValue); - updateValueUpdatedLabel(returnValue); onChange?.(returnValue); - setInputInnerValue(returnValue); + if (!isControlled) setInputInnerValue(returnValue); setDisplayValue(formatNumber(returnValue)); }; @@ -199,7 +230,7 @@ export const NumberField = (props: NumberFieldProps) => { if (rawValue === '') { if (currentValue !== undefined) { onChange?.(undefined); - setInputInnerValue(undefined); + if (!isControlled) setInputInnerValue(undefined); } return; } @@ -218,7 +249,7 @@ export const NumberField = (props: NumberFieldProps) => { if (rounded !== currentValue) { onChange?.(rounded); - setInputInnerValue(rounded); + if (!isControlled) setInputInnerValue(rounded); } }; @@ -240,7 +271,10 @@ export const NumberField = (props: NumberFieldProps) => { [styles['tedi-number-field__button--disabled']]: isOnOrOutOfBounds || disabled, }); - const changeValue = getLabel(`numberField.${direction}`, step); + // Include the field's name so the button is self-describing out of context, + // e.g. "Decrease Label by 1" rather than a bare "Decrease by 1". + const fieldName = typeof label === 'string' ? label : undefined; + const changeValue = getLabel(`numberField.${direction}`, step, fieldName); return (