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
5 changes: 3 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export default [{
"packages/dev/storybook-builder-parcel/*",
"packages/dev/storybook-react-parcel/*",
"packages/dev/s2-docs/pages/**",
"packages/dev/mcp/*/dist"
"packages/dev/mcp/*/dist",
"packages/dev/codemods/src/s1-to-s2/__testfixtures__/cli/**"
],
}, ...compat.extends("eslint:recommended"), {
plugins: {
Expand Down Expand Up @@ -534,4 +535,4 @@ export default [{
...globals.browser
}
}
}];
}];
59 changes: 38 additions & 21 deletions packages/@internationalized/number/src/NumberParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ interface Symbols {
group?: string,
literals: RegExp,
numeral: RegExp,
index: (v: string) => string
numerals: string[],
index: (v: string) => string,
noNumeralUnits: Array<{unit: string, value: number}>
}

const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$');
Expand Down Expand Up @@ -130,13 +132,17 @@ class NumberParserImpl {
}

parse(value: string) {
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
// to parse the number, we need to remove anything that isn't actually part of the number, for example we want '-10.40' not '-10.40 USD'
let fullySanitizedValue = this.sanitize(value);

if (this.symbols.group) {
// Remove group characters, and replace decimal points and numerals with ASCII values.
fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, '');
// Return NaN if there is a group symbol but useGrouping is false
if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) {
return NaN;
} else if (this.symbols.group) {
fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, '');
}

if (this.symbols.decimal) {
fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.');
}
Expand Down Expand Up @@ -189,12 +195,17 @@ class NumberParserImpl {
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
newValue = -1 * newValue;
}

return newValue;
}

sanitize(value: string) {
// Remove literals and whitespace, which are allowed anywhere in the string
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
// If the value is only a unit and it matches one of the formatted numbers where the value is part of the unit and doesn't have any numerals, then
// return the known value for that case.
if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) {
return this.symbols.noNumeralUnits.find(obj => obj.unit === value)!.value.toString();
}

value = value.replace(this.symbols.literals, '');

// Replace the ASCII minus sign with the minus sign used in the current locale
Expand All @@ -207,23 +218,23 @@ class NumberParserImpl {
// instead they use the , (44) character or apparently the (1548) character.
if (this.options.numberingSystem === 'arab') {
if (this.symbols.decimal) {
value = value.replace(',', this.symbols.decimal);
value = value.replace(String.fromCharCode(1548), this.symbols.decimal);
value = replaceAll(value, ',', this.symbols.decimal);
value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal);
}
if (this.symbols.group) {
if (this.symbols.group && isGroupSymbolAllowed) {
value = replaceAll(value, '.', this.symbols.group);
}
}

// In some locale styles, such as swiss currency, the group character can be a special single quote
// that keyboards don't typically have. This expands the character to include the easier to type single quote.
if (this.symbols.group === '’' && value.includes("'")) {
if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) {
value = replaceAll(value, "'", this.symbols.group);
}

// fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard,
// so allow space and non-breaking space as a group char as well
if (this.options.locale === 'fr-FR' && this.symbols.group) {
if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) {
value = replaceAll(value, ' ', this.symbols.group);
value = replaceAll(value, /\u00A0/g, this.symbols.group);
}
Expand All @@ -232,6 +243,7 @@ class NumberParserImpl {
}

isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean {
let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping;
value = this.sanitize(value);

// Remove minus or plus sign, which must be at the start of the string.
Expand All @@ -241,18 +253,13 @@ class NumberParserImpl {
value = value.slice(this.symbols.plusSign.length);
}

// Numbers cannot start with a group separator
if (this.symbols.group && value.startsWith(this.symbols.group)) {
return false;
}

// Numbers that can't have any decimal values fail if a decimal character is typed
if (this.symbols.decimal && value.indexOf(this.symbols.decimal) > -1 && this.options.maximumFractionDigits === 0) {
return false;
}

// Remove numerals, groups, and decimals
if (this.symbols.group) {
if (this.symbols.group && isGroupSymbolAllowed) {
value = replaceAll(value, this.symbols.group, '');
}
value = value.replace(this.symbols.numeral, '');
Expand Down Expand Up @@ -282,12 +289,21 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I
maximumSignificantDigits: 21,
roundingIncrement: 1,
roundingPriority: 'auto',
roundingMode: 'halfExpand'
roundingMode: 'halfExpand',
useGrouping: true
});
// Note: some locale's don't add a group symbol until there is a ten thousands place
let allParts = symbolFormatter.formatToParts(-10000.111);
let posAllParts = symbolFormatter.formatToParts(10000.111);
let pluralParts = pluralNumbers.map(n => symbolFormatter.formatToParts(n));
// if the plural parts include a unit but no integer or fraction, then we need to add the unit to the special set
let noNumeralUnits = pluralParts.map((p, i) => {
let unit = p.find(p => p.type === 'unit');
if (unit && !p.some(p => p.type === 'integer' || p.type === 'fraction')) {
return {unit: unit.value, value: pluralNumbers[i]};
}
return null;
}).filter(p => !!p);

let minusSign = allParts.find(p => p.type === 'minusSign')?.value ?? '-';
let plusSign = posAllParts.find(p => p.type === 'plusSign')?.value;
Expand All @@ -311,17 +327,18 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I
let pluralPartsLiterals = pluralParts.flatMap(p => p.filter(p => !nonLiteralParts.has(p.type)).map(p => escapeRegex(p.value)));
let sortedLiterals = [...new Set([...allPartsLiterals, ...pluralPartsLiterals])].sort((a, b) => b.length - a.length);

// Match both whitespace and formatting characters
let literals = sortedLiterals.length === 0 ?
new RegExp('[\\p{White_Space}]', 'gu') :
new RegExp(`${sortedLiterals.join('|')}|[\\p{White_Space}]`, 'gu');
new RegExp('\\p{White_Space}|\\p{Cf}', 'gu') :
new RegExp(`${sortedLiterals.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu');

// These are for replacing non-latn characters with the latn equivalent
let numerals = [...new Intl.NumberFormat(intlOptions.locale, {useGrouping: false}).format(9876543210)].reverse();
let indexes = new Map(numerals.map((d, i) => [d, i]));
let numeral = new RegExp(`[${numerals.join('')}]`, 'g');
let index = d => String(indexes.get(d));

return {minusSign, plusSign, decimal, group, literals, numeral, index};
return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index, noNumeralUnits};
}

function replaceAll(str: string, find: string | RegExp, replace: string) {
Expand Down
95 changes: 92 additions & 3 deletions packages/@internationalized/number/test/NumberParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ describe('NumberParser', function () {
expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN);
});

it('should return NaN for invalid grouping', function () {
expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN();
expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN();
});

describe('currency', function () {
it('should parse without the currency symbol', function () {
expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5);
Expand Down Expand Up @@ -194,8 +199,13 @@ describe('NumberParser', function () {
expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000);
});

it('should parse arabic singular and dual counts', () => {
expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يومان')).toBe(2);
expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يوم')).toBe(1);
});

describe('round trips', function () {
fc.configureGlobal({numRuns: 200});
fc.configureGlobal({numRuns: 2000});
// 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);
Expand Down Expand Up @@ -301,6 +311,78 @@ describe('NumberParser', function () {
const formattedOnce = formatter.format(1);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle small numbers', () => {
let locale = 'ar-AE';
let options = {
style: 'decimal',
minimumIntegerDigits: 4,
maximumSignificantDigits: 1
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(2.220446049250313e-16);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle currency small numbers', () => {
let locale = 'ar-AE-u-nu-latn';
let options = {
style: 'currency',
currency: 'USD'
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(2.220446049250313e-16);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle hanidec small numbers', () => {
let locale = 'ar-AE-u-nu-hanidec';
let options = {
style: 'decimal'
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(2.220446049250313e-16);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle beng with minimum integer digits', () => {
let locale = 'ar-AE-u-nu-beng';
let options = {
style: 'decimal',
minimumIntegerDigits: 4,
maximumFractionDigits: 0
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(2.220446049250313e-16);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle percent with minimum integer digits', () => {
let locale = 'ar-AE-u-nu-latn';
let options = {
style: 'percent',
minimumIntegerDigits: 4,
minimumFractionDigits: 9,
maximumSignificantDigits: 1,
maximumFractionDigits: undefined
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(0.0095);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
it('should handle non-grouping in russian locale', () => {
let locale = 'ru-RU';
let options = {
style: 'percent',
useGrouping: false,
minimumFractionDigits: undefined,
maximumFractionDigits: undefined
};
const formatter = new Intl.NumberFormat(locale, options);
const parser = new NumberParser(locale, options);
const formattedOnce = formatter.format(2.220446049250313e-16);
expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce);
});
});
});

Expand All @@ -327,14 +409,21 @@ describe('NumberParser', function () {
});

it('should support group characters', function () {
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); // en-US-u-nu-arab uses commas as the decimal point character
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(false); // latin numerals cannot follow arab decimal point
// starting with arabic decimal point
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('000,000')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000,000')).toBe(true);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true);
});

it('should return false for invalid grouping', function () {
expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false);
expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false);
});

it('should reject random characters', function () {
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false);
expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false);
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/combobox/src/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
break;
}
}
state.commit();
if (e.key === 'Enter' || state.isOpen) {
state.commit();
}
break;
case 'Escape':
if (
Expand Down
17 changes: 17 additions & 0 deletions packages/@react-aria/combobox/test/useComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ describe('useComboBox', function () {
expect(preventDefault).toHaveBeenCalledTimes(1);
});

it('should only call commit on Tab when the menu is open', function () {
let commitSpy = jest.fn();
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
let closedState = {...state.current, isOpen: false, commit: commitSpy};
let {result: closedResult} = renderHook((props) => useComboBox(props, closedState), {initialProps: props});
act(() => {
closedResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
});
expect(commitSpy).toHaveBeenCalledTimes(0);
let openState = {...state.current, isOpen: true, commit: commitSpy};
let {result: openResult} = renderHook((props) => useComboBox(props, openState), {initialProps: props});
act(() => {
openResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
});
expect(commitSpy).toHaveBeenCalledTimes(1);
});

it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () {
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
state.current.open = openSpy;
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/dnd/stories/VirtualizedListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ React.forwardRef(function (props: any, ref) {
<Context.Provider value={{state, dropState}}>
<Virtualizer
{...mergeProps(collectionProps, listBoxProps)}
onScroll={undefined}
ref={domRef}
className={classNames(dndStyles, 'droppable-collection', 'is-virtualized', {'is-drop-target': isDropTarget})}
scrollDirection="vertical"
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ function useFocusContainment(scopeRef: RefObject<Element[] | null>, contain?: bo
e.preventDefault();
if (nextElement) {
focusElement(nextElement, true);
if (nextElement instanceof getOwnerWindow(nextElement).HTMLInputElement) {
nextElement.select();
}
}
};

Expand Down
21 changes: 21 additions & 0 deletions packages/@react-aria/focus/test/FocusScope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,27 @@ describe('FocusScope', function () {

expect(document.activeElement).toBe(input2);
});

it('should select all text in input when tabbing', async function () {
let {getByTestId} = render(
<FocusScope contain>
<input data-testid="input1" defaultValue="Test1" />
<input data-testid="input2" defaultValue="Test2" />
<input data-testid="input3" defaultValue="Test3" />
</FocusScope>
);

let input1 = getByTestId('input1');
let input2 = getByTestId('input2');

act(() => {input1.focus();});
expect(document.activeElement).toBe(input1);

await user.tab();
expect(document.activeElement).toBe(input2);
await user.keyboard('{Delete}');
expect(input2.value).toBe('');
});
});

describe('focus restoration', function () {
Expand Down
Loading
Loading