Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/tedi/components/form/number-field/number-field.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<NumberField label="Amount" value={7} min={0} max={10} />);
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(<NumberField label="Amount" onChange={jest.fn()} />);
expect(screen.getByRole('spinbutton')).toHaveAccessibleName('Amount');
});

it('gives each field a unique id so labels never cross-associate', () => {
render(
<>
<NumberField label="First" onChange={jest.fn()} />
<NumberField label="Second" onChange={jest.fn()} />
</>
);

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(
<LabelProvider locale="en">
<NumberField label="Amount" step={2} onChange={jest.fn()} />
</LabelProvider>
);

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(
<LabelProvider locale="en">
<NumberField label="Amount" value={1} onChange={jest.fn()} />
</LabelProvider>
);
const status = screen.getByRole('status');
expect(status).toHaveTextContent('');

rerender(
<LabelProvider locale="en">
<NumberField label="Amount" value={2} onChange={jest.fn()} />
</LabelProvider>
);
act(() => {
jest.advanceTimersByTime(150);
});

// Assert the user-facing localized announcement, not the fallback key.
expect(status).toHaveTextContent('Updated. New value 2');
} finally {
jest.useRealTimers();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const TemplateSizes: StoryFn<NumberFieldProps> = (args) => {

export const Default: Story = {
args: {
id: 'example-1',
label: 'Label',
defaultValue: 1,
step: 1,
Expand Down Expand Up @@ -140,18 +139,15 @@ export const States: Story = {

export const WithHint: Story = {
args: {
id: 'example-1',
label: 'Label',
helper: {
id: 'example-3',
text: 'Hint text',
type: 'hint',
},
},
};
export const Decimal: Story = {
args: {
id: 'example-1',
label: 'Label',
defaultValue: 1.5,
step: 0.25,
Expand All @@ -162,7 +158,6 @@ export const Decimal: Story = {

export const WithUnit: Story = {
args: {
id: 'example-2',
label: 'Label',
defaultValue: 2,
step: 1,
Expand All @@ -174,7 +169,6 @@ export const WithUnit: Story = {

export const FullWidth: Story = {
args: {
id: 'example-3',
label: 'Label',
value: 2,
step: 1,
Expand Down
78 changes: 55 additions & 23 deletions src/tedi/components/form/number-field/number-field.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -117,6 +117,9 @@ export const NumberField = (props: NumberFieldProps) => {
input,
} = getCurrentBreakpointProps<NumberFieldProps>(props);

const generatedId = useId();
const resolvedId = id ?? generatedId;

const resolvedInputMode =
inputMode ?? ((decimalPlaces && decimalPlaces > 0) || decimalSeparator === ',' ? 'decimal' : 'numeric');

Expand All @@ -131,19 +134,24 @@ export const NumberField = (props: NumberFieldProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const isFocusedRef = useRef(false);

const [inputUpdated, setInputUpdated] = useState<string>('');
const [announcement, setAnnouncement] = useState<string>('');
const [inputInnerValue, setInputInnerValue] = useState<number | undefined>(defaultValue);
const [displayValue, setDisplayValue] = useState<string>(() => 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<ReturnType<typeof setTimeout>>();
const announceHideTimer = useRef<ReturnType<typeof setTimeout>>();
const prevValueRef = useRef<number | undefined>(currentValue);

useEffect(() => {
if (!isFocusedRef.current && typeof value !== 'undefined') {
setDisplayValue(formatNumber(value));
}
}, [value, formatNumber]);

const helperId = helper ? `${id}-helper` : undefined;
const helperId = helper ? `${resolvedId}-helper` : undefined;

const isInvalid = useCallback(
(currentValue: number | undefined): boolean => {
Expand All @@ -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;
Expand All @@ -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));
};

Expand All @@ -199,7 +230,7 @@ export const NumberField = (props: NumberFieldProps) => {
if (rawValue === '') {
if (currentValue !== undefined) {
onChange?.(undefined);
setInputInnerValue(undefined);
if (!isControlled) setInputInnerValue(undefined);
}
return;
}
Expand All @@ -218,7 +249,7 @@ export const NumberField = (props: NumberFieldProps) => {

if (rounded !== currentValue) {
onChange?.(rounded);
setInputInnerValue(rounded);
if (!isControlled) setInputInnerValue(rounded);
}
};

Expand All @@ -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 (
<Button
Expand Down Expand Up @@ -270,7 +304,7 @@ export const NumberField = (props: NumberFieldProps) => {
<div className={InputWrapperBEM} onClick={focusInput}>
<input
ref={inputRef}
id={id}
id={resolvedId}
role="spinbutton"
aria-valuemin={min}
aria-valuemax={max}
Expand Down Expand Up @@ -307,18 +341,16 @@ export const NumberField = (props: NumberFieldProps) => {

return (
<div data-name="number-field" className={className}>
<FormLabel id={id} label={label} required={required} hideLabel={hideLabel} size={size} />
<FormLabel id={resolvedId} label={label} required={required} hideLabel={hideLabel} size={size} />
<div className={NumberFieldBem}>
{renderButton('decrement')}
{renderInputElement()}
{renderButton('increment')}
</div>
{helper && <FeedbackText className={styles['tedi-number-field__feedback']} {...helper} id={helperId} />}
{inputUpdated && (
<div aria-live="polite" className="sr-only">
{inputUpdated}
</div>
)}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</div>
);
};
Expand Down
22 changes: 13 additions & 9 deletions src/tedi/providers/label-provider/labels-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,25 +1259,29 @@ export const labelsMap = validateDefaultLabels({
ru: 'Нижний колонтитул',
},
'numberField.decrement': {
description: 'Label for screen-reader for number field decrease button',
description: 'Label for screen-reader for number field decrease button. Second argument is the field label.',
components: ['NumberField'],
et: (count: string | number) => `Vähenda ${count} võrra`,
en: (count: string | number) => `Decrease by ${count}`,
ru: (count: string | number) => `Уменьшить на ${count}`,
et: (count: string | number, field?: string) =>
field ? `Vähenda välja "${field}" ${count} võrra` : `Vähenda ${count} võrra`,
en: (count: string | number, field?: string) => (field ? `Decrease ${field} by ${count}` : `Decrease by ${count}`),
ru: (count: string | number, field?: string) =>
field ? `Уменьшить «${field}» на ${count}` : `Уменьшить на ${count}`,
},
'numberField.increment': {
description: 'Label for screen-reader for number field increase button',
description: 'Label for screen-reader for number field increase button. Second argument is the field label.',
components: ['NumberField'],
et: (count: string | number) => `Suurenda ${count} võrra`,
en: (count: string | number) => `Increase by ${count}`,
ru: (count: string | number) => `Увеличить на ${count}`,
et: (count: string | number, field?: string) =>
field ? `Suurenda välja "${field}" ${count} võrra` : `Suurenda ${count} võrra`,
en: (count: string | number, field?: string) => (field ? `Increase ${field} by ${count}` : `Increase by ${count}`),
ru: (count: string | number, field?: string) =>
field ? `Увеличить «${field}» на ${count}` : `Увеличить на ${count}`,
},
'numberField.quantityUpdated': {
description: 'Label for screen-reader when quantity get updated by button click',
components: ['NumberField'],
et: (count: string | number) => `Uuendatud. Uus väärtus ${count}`,
en: (count: string | number) => `Updated. New value ${count}`,
ru: (count: string | number) => `Ууэндатуд. Уус вяэртус ${count}`,
ru: (count: string | number) => `Обновлено. Новое значение ${count}`,
},
'sidenav.backToMainMenu': {
description: 'Side navigation label',
Expand Down
Loading