Skip to content
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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

majornista
Copy link
Collaborator

Closes #4967

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue #4967.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 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

@rspbot
Copy link

rspbot commented Jun 11, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 5a5e783 to f86672a Compare June 19, 2024 17:23
@majornista majornista requested a review from snowystinger June 19, 2024 19:19
@rspbot
Copy link

rspbot commented Jun 19, 2024

Copy link
Member

@snowystinger snowystinger left a 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

  1. we have a random number
  2. we format the number to a string so we know it's valid
  3. we send that string into the NumberField via paste
  4. 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}
      );
    });

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 7b708f7 to 4f2457c Compare June 24, 2024 16:34
@majornista
Copy link
Collaborator Author

@snowystinger I think I may have fixed the NumberParser sanitize method so that should fully reverse NumberFormat test no longer needs to be skipped. Could you take a look?

@majornista majornista requested a review from snowystinger June 24, 2024 16:39
@rspbot
Copy link

rspbot commented Jun 24, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 4f2457c to 5fdf204 Compare June 24, 2024 18:52
@rspbot
Copy link

rspbot commented Jun 24, 2024

@snowystinger
Copy link
Member

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

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 5fdf204 to e196cf0 Compare June 26, 2024 15:32
@rspbot
Copy link

rspbot commented Jun 26, 2024

@rspbot
Copy link

rspbot commented Jun 26, 2024

@rspbot
Copy link

rspbot commented Jul 10, 2024

@rspbot
Copy link

rspbot commented Jul 10, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from d6bc520 to 3a44259 Compare July 11, 2024 13:37
@rspbot
Copy link

rspbot commented Jul 11, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 3a44259 to 7b59721 Compare July 15, 2024 14:28
@rspbot
Copy link

rspbot commented Jul 15, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 7b59721 to 3795e27 Compare July 15, 2024 16:52
@majornista
Copy link
Collaborator Author

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.

@rspbot
Copy link

rspbot commented Jul 15, 2024

@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from 3795e27 to 126765a Compare July 15, 2024 21:02
@rspbot
Copy link

rspbot commented Oct 23, 2024

… 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.
@majornista majornista force-pushed the Issue-4967-useNumberField-input-masking branch from dd7cb2a to a287fb7 Compare October 28, 2024 14:15
@rspbot
Copy link

rspbot commented Oct 28, 2024

@rspbot
Copy link

rspbot commented Nov 12, 2024

@rspbot
Copy link

rspbot commented Nov 19, 2024

@rspbot
Copy link

rspbot commented Dec 3, 2024

@rspbot
Copy link

rspbot commented Dec 4, 2024

@rspbot
Copy link

rspbot commented Dec 5, 2024

@rspbot
Copy link

rspbot commented Jan 2, 2025

@rspbot
Copy link

rspbot commented Jan 2, 2025

## 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
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

useNumberField mutates number so screenreader users won’t know that their value has changed
3 participants