-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: #4967 useNumberField mutates number so screenreader users won’t know that their value has changed #6520
base: main
Are you sure you want to change the base?
Conversation
5a5e783
to
f86672a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not quite sure it's working, we should be able to thoroughly test this using fast-check
code taken mostly from 'NumberParser.test', I'm not positive I have it set up correctly, but right now it doesn't appear to be making the round trip. General idea is
- we have a random number
- we format the number to a string so we know it's valid
- we send that string into the NumberField via paste
- we should see an onChange with the value of the number we formatted earlier and we should see the input reflect the number as formatted our current locale
// for some reason hu-HU isn't supported in jsdom/node
let locales = Object.keys(messages).filter(locale => locale !== 'hu-HU');
describe.only('round trips', function () {
// Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others
// But for the moment they are not properly supported
const localesArb = fc.constantFrom(...locales);
const styleOptsArb = fc.oneof(
{withCrossShrink: true},
fc.record({style: fc.constant('decimal')}),
// 'percent' should be part of the possible options, but for the moment it fails for some tests
fc.record({style: fc.constant('percent')}),
fc.record(
{style: fc.constant('currency'), currency: fc.constantFrom('USD', 'EUR', 'CNY', 'JPY'), currencyDisplay: fc.constantFrom('symbol', 'code', 'name')},
{requiredKeys: ['style', 'currency']}
),
fc.record(
{style: fc.constant('unit'), unit: fc.constantFrom('inch', 'liter', 'kilometer-per-hour')},
{requiredKeys: ['style', 'unit']}
)
);
const genericOptsArb = fc.record({
localeMatcher: fc.constantFrom('best fit', 'lookup'),
unitDisplay: fc.constantFrom('narrow', 'short', 'long'),
useGrouping: fc.boolean(),
minimumIntegerDigits: fc.integer({min: 1, max: 21}),
minimumFractionDigits: fc.integer({min: 0, max: 20}),
maximumFractionDigits: fc.integer({min: 0, max: 20}),
minimumSignificantDigits: fc.integer({min: 1, max: 21}),
maximumSignificantDigits: fc.integer({min: 1, max: 21})
}, {requiredKeys: []});
// We restricted the set of possible values to avoid unwanted overflows to infinity and underflows to zero
// and stay in the domain of legit values.
const DOUBLE_MIN = Number.EPSILON;
const valueArb = fc.tuple(
fc.constantFrom(1, -1),
fc.double({next: true, noNaN: true, min: DOUBLE_MIN, max: 1 / DOUBLE_MIN})
).map(([sign, value]) => sign * value);
const inputsArb = fc.tuple(valueArb, localesArb, styleOptsArb, genericOptsArb)
.map(([d, locale, styleOpts, genericOpts]) => ({d, opts: {...styleOpts, ...genericOpts}, locale}))
.filter(({opts}) => opts.minimumFractionDigits === undefined || opts.maximumFractionDigits === undefined || opts.minimumFractionDigits <= opts.maximumFractionDigits)
.filter(({opts}) => opts.minimumSignificantDigits === undefined || opts.maximumSignificantDigits === undefined || opts.minimumSignificantDigits <= opts.maximumSignificantDigits)
.map(({d, opts, locale}) => {
if (opts.style === 'percent') {
opts.minimumFractionDigits = opts.minimumFractionDigits > 18 ? 18 : opts.minimumFractionDigits;
opts.maximumFractionDigits = opts.maximumFractionDigits > 18 ? 18 : opts.maximumFractionDigits;
}
return {d, opts, locale};
})
.map(({d, opts, locale}) => {
let adjustedNumberForFractions = d;
if (Math.abs(d) < 1 && opts.minimumFractionDigits && opts.minimumFractionDigits > 1) {
adjustedNumberForFractions = d * (10 ** (opts.minimumFractionDigits || 2));
} else if (Math.abs(d) > 1 && opts.minimumFractionDigits && opts.minimumFractionDigits > 1) {
adjustedNumberForFractions = d / (10 ** (opts.minimumFractionDigits || 2));
}
return {adjustedNumberForFractions, opts, locale};
});
it('should fully reverse NumberFormat', async function () {
let onChange = jest.fn((val) => console.log(val));
let {getByRole} = render(<TestNumberField onChange={onChange} />);
await fc.assert(
fc.asyncProperty(
inputsArb,
fc.scheduler({act}),
async function ({adjustedNumberForFractions, locale, opts}, s) {
s.scheduleSequence([{builder: async () => {
const formatter = new Intl.NumberFormat(locale, opts);
const parser = new NumberParser(locale, opts);
const formattedOnce = formatter.format(adjustedNumberForFractions);
let input = getByRole('textbox');
act(() => {
input.focus();
input.setSelectionRange(0, input.value.length);
});
await userEvent.paste(formattedOnce);
}, label: `entering ${adjustedNumberForFractions}`}]);
// Assert
while (s.count() !== 0) {
await s.waitOne();
console.log('mock call', onChange.mock.calls)
expect(onChange).toHaveBeenLastCalledWith(adjustedNumberForFractions);
}
}
),
{numRuns: 1000, timeout: 1000}
);
});
7b708f7
to
4f2457c
Compare
@snowystinger I think I may have fixed the |
4f2457c
to
5fdf204
Compare
Thanks, had a look, I think we still need to resolve a few things as I couldn't get the test I suggested in my comment above working. See https://github.com/adobe/react-spectrum/compare/Issue-4967-useNumberField-input-masking...numberfield-testing-and-questions?expand=1 |
5fdf204
to
e196cf0
Compare
d6bc520
to
3a44259
Compare
3a44259
to
7b59721
Compare
7b59721
to
3795e27
Compare
It’s still not perfect, but it’s a bit better. It becomes difficult to round trip parsing values with units from other the locales, percent, currency (accounting), and negative values. Rather than fail the, I trace out any values that fail to match after parsing when pasting from one locale to another, in the test case en-US : https://github.com/adobe/react-spectrum/pull/6520/commits/3a44259268d3fe945fa59d10027709a88ba1297c#diff-7bf83599612e94f27f[…]02e2ea6515e426R244-R246. |
3795e27
to
126765a
Compare
955544e
to
dd7cb2a
Compare
… know that their value has changed Improvements to behavior when pasting a value from a different locale into a NumberField. A pasted string containing groups and a decimal, like "3 000 000.25", "3,000,000.25" or "3.000.000,25", will now attempt to parse using supported locales rather than only parsing using the current locale. Where there is ambiguity, as with "1,000" or "1.000", the pasted value will be parsed using the current locale. For example, "1.000" will be interpreted as "1" in en-US, and "1,000" will be "1" in de-DE. If the pasted text is different from the resulting input text, the screen reader should announce "Pasted value: {value}" politely, so that the user hears the new value.
…mbering systems 1. If the value fails to parse using default parser and numbering system for the locale, try other locales and numbering systems. 2. Use a RegEx to capture the decimal part of the value. 3. Improve logic for replacing decimal symbol in the value with the decimal symbol for the current locale. Ambiguous values may still fail to round trip. For example 123,456, will be parsed as 123.456 in locales that use a comma as the decimal symbol, but 123456 in those that use a period as the decimal symbol. "Round trips should fully reverse NumberFormat" test will console.log the details of any of these failures. 3. Strip leading zeros from the integer part using a RegEx rather than parseInt. 4. Handle accounting formatting when handling literals after the last numeral in the value.
dd7cb2a
to
a287fb7
Compare
…ield-input-masking
## API Changes
@react-stately/color/@react-stately/color:ColorChannelFieldState ColorChannelFieldState {
canDecrement: boolean
canIncrement: boolean
colorValue: Color
commit: () => void
commitValidation: () => void
decrement: () => void
decrementToMin: () => void
displayValidation: ValidationResult
increment: () => void
incrementToMax: () => void
inputValue: string
maxValue?: number
minValue?: number
numberValue: number
+ parseValueInAnySupportedLocale: (string) => number
realtimeValidation: ValidationResult
resetValidation: () => void
setInputValue: (string) => void
setNumberValue: (number) => void
validate: (string) => boolean
} @react-stately/numberfield/@react-stately/numberfield:NumberFieldState NumberFieldState {
canDecrement: boolean
canIncrement: boolean
commit: () => void
commitValidation: () => void
decrement: () => void
decrementToMin: () => void
displayValidation: ValidationResult
increment: () => void
incrementToMax: () => void
inputValue: string
maxValue?: number
minValue?: number
numberValue: number
+ parseValueInAnySupportedLocale: (string) => number
realtimeValidation: ValidationResult
resetValidation: () => void
setInputValue: (string) => void
setNumberValue: (number) => void
validate: (string) => boolean
} |
Closes #4967
✅ Pull Request Checklist:
📝 Test Instructions:
Improves behavior when pasting a value from a different locale into a NumberField.
A pasted string containing groups and a decimal, like "3 000 000.25", "3,000,000.25" or "3.000.000,25", will now attempt to parse using supported locales rather than only parsing using the current locale.
Where there is ambiguity, as with "1,000" or "1.000", the pasted value will be parsed using the current locale. For example, "1.000" will be interpreted as "1" in en-US, and "1,000" will be "1" in de-DE.
If the pasted text is different from the resulting input text, the screen reader should announce "Pasted value: {value}" politely, so that the user hears the new value.
🧢 Your Project:
Adobe/Accessibility