diff --git a/packages/@react-aria/i18n/src/context.tsx b/packages/@react-aria/i18n/src/context.tsx index 34ffc5797c4..d7d8f1a6d0b 100644 --- a/packages/@react-aria/i18n/src/context.tsx +++ b/packages/@react-aria/i18n/src/context.tsx @@ -23,31 +23,60 @@ export interface I18nProviderProps { const I18nContext = React.createContext(null); +interface I18nProviderWithLocaleProps extends I18nProviderProps { + locale: string +} + /** - * Provides the locale for the application to all child components. + * Internal component that handles the case when locale is provided. */ -export function I18nProvider(props: I18nProviderProps): JSX.Element { - let {locale, children} = props; - let defaultLocale = useDefaultLocale(); +function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element { + let {locale, children} = props; + let value: Locale = React.useMemo(() => ({ + locale, + direction: isRTL(locale) ? 'rtl' : 'ltr' + }), [locale]); - let value: Locale = React.useMemo(() => { - if (!locale) { - return defaultLocale; - } + return ( + + {children} + + ); +} - return { - locale, - direction: isRTL(locale) ? 'rtl' : 'ltr' - }; - }, [defaultLocale, locale]); +interface I18nProviderWithDefaultLocaleProps { + children: ReactNode +} + +/** + * Internal component that handles the case when no locale is provided. + */ +function I18nProviderWithDefaultLocale(props: I18nProviderWithDefaultLocaleProps): JSX.Element { + let {children} = props; + let defaultLocale = useDefaultLocale(); return ( - + {children} ); } +/** + * Provides the locale for the application to all child components. + */ +export function I18nProvider(props: I18nProviderProps): JSX.Element { + let {locale, children} = props; + + // Conditionally render different components to avoid calling useDefaultLocale. + // This is necessary because useDefaultLocale triggers a re-render. + if (locale) { + return ; + } + + return ; +} + /** * Returns the current locale and layout direction. */ diff --git a/packages/@react-aria/i18n/test/languagechange.test.js b/packages/@react-aria/i18n/test/languagechange.test.js new file mode 100644 index 00000000000..c18275ebd54 --- /dev/null +++ b/packages/@react-aria/i18n/test/languagechange.test.js @@ -0,0 +1,131 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, render} from '@react-spectrum/test-utils-internal'; +import {I18nProvider, useLocale} from '../src/context'; +import React from 'react'; + +function TestComponent() { + let locale = useLocale(); + return ( +
+
{locale.locale}
+
{locale.direction}
+
+ ); +} + +function languageProps(language) { + return { + value: language, + writable: true, + configurable: true + }; +} + +describe('useLocale languagechange', () => { + let originalNavigator; + let originalLanguage; + + beforeEach(() => { + originalNavigator = window.navigator; + originalLanguage = window.navigator.language; + + Object.defineProperty(window.navigator, 'language', languageProps('en-US')); + + act(() => { + window.dispatchEvent(new Event('languagechange')); + }); + }); + + afterEach(() => { + Object.defineProperty(window.navigator, 'language', languageProps(originalLanguage)); + + act(() => { + window.dispatchEvent(new Event('languagechange')); + }); + + Object.defineProperty(window, 'navigator', languageProps(originalNavigator)); + }); + + it('should update locale when languagechange event is triggered', () => { + let {getByTestId} = render( + + + + ); + + // Initial render should show en-US + expect(getByTestId('locale')).toHaveTextContent('en-US'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + + // Change navigator.language and trigger languagechange event + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('pt-PT')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should re-render with new locale + expect(getByTestId('locale')).toHaveTextContent('pt-PT'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); + + it('should update locale direction when changing from LTR to RTL language', () => { + let {getByTestId} = render( + + + + ); + + // Change to Hebrew (RTL language) + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('he-IL')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should update to Hebrew with RTL direction + expect(getByTestId('locale')).toHaveTextContent('he-IL'); + expect(getByTestId('direction')).toHaveTextContent('rtl'); + + // Change back to Portuguese + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('pt-PT')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should update to Portuguese + expect(getByTestId('locale')).toHaveTextContent('pt-PT'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); + + it('should not change displayed locale when explicit locale is provided via I18nProvider', () => { + let {getByTestId} = render( + + + + ); + + // Initial render should show fr-FR + expect(getByTestId('locale')).toHaveTextContent('fr-FR'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + + // Change navigator.language and trigger languagechange event + act(() => { + Object.defineProperty(window.navigator, 'language', languageProps('ja-JP')); + window.dispatchEvent(new Event('languagechange')); + }); + + // Should still show fr-FR (explicit locale takes precedence) + expect(getByTestId('locale')).toHaveTextContent('fr-FR'); + expect(getByTestId('direction')).toHaveTextContent('ltr'); + }); +}); diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 73f1c306fff..c5574309aec 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, screen, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; @@ -70,7 +70,7 @@ describe('NumberField', function () { incrementButton, decrementButton, debug, - rerender: (props = {}, locale) => rerender() + rerender: (props = {}, locale) => rerender() }; } @@ -871,7 +871,7 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', '€10.00'); rerender({defaultValue: 10, formatOptions: {style: 'currency', currency: 'USD'}}); - expect(textField).toHaveAttribute('value', '$10.00'); + expect(screen.getByRole('textbox')).toHaveAttribute('value', '$10.00'); }); it.each` @@ -2280,7 +2280,7 @@ describe('NumberField', function () { expect(hiddenInput).toHaveValue('30'); rerender({name: 'age', value: null}); - expect(hiddenInput).toHaveValue(''); + expect(document.querySelector('input[type=hidden]')).toHaveValue(''); }); it('supports form reset', async () => { @@ -2314,7 +2314,7 @@ describe('NumberField', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => 33, 22); - + return (