From a128ccbe9441792b6096c6ea50b3762c7dcfedee Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 4 Aug 2025 16:28:20 +0200 Subject: [PATCH] [web] Add Intl.Locale to parse browser languages. (#172964) Closes https://github.com/flutter/flutter/issues/130174 ### Description - Adds `DomLocale` extension type for [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) - Replaces manual browser language parsing with `DomLocale` usage - Adds tests to cover new functionality ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../lib/web_ui/lib/src/engine/dom.dart | 44 +++++++++++++ .../lib/src/engine/platform_dispatcher.dart | 29 ++++++--- .../lib/web_ui/test/engine/locale_test.dart | 63 +++++++++++++++++++ .../platform_dispatcher_test.dart | 28 +++++++++ 4 files changed, 157 insertions(+), 7 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart index a93b6f3a9d2..3939217bfc2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart @@ -2444,6 +2444,50 @@ extension type DomSegments._(JSObject _) implements JSObject { } } +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale +@JS('Intl.Locale') +extension type DomLocale._(JSObject _) implements JSObject { + external DomLocale(String tag, [DomLocaleOptions? options]); + + external String get language; + external String? get script; + external String? get region; + external String? get calendar; + external String? get caseFirst; + external String? get collation; + external String? get hourCycle; + external String? get numberingSystem; + external bool? get numeric; + + @JS('toString') + external String toJSString(); +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/Locale#options +extension type DomLocaleOptions._(JSObject _) implements JSObject { + external DomLocaleOptions({ + String? language, + String? script, + String? region, + String? calendar, + String? caseFirst, + String? collation, + String? hourCycle, + String? numberingSystem, + bool? numeric, + }); + + external String? get language; + external String? get script; + external String? get region; + external String? get calendar; + external String? get caseFirst; + external String? get collation; + external String? get hourCycle; + external String? get numberingSystem; + external bool? get numeric; +} + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols @JS('Iterator') extension type DomIterator._(JSObject _) implements JSObject { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 01934cc6232..58018a8b9d2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -901,9 +901,22 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { configuration = configuration.copyWith(locales: parseBrowserLanguages()); } + /// Overrides the browser languages list. + /// + /// If [value] is null, resets the browser languages back to the real value. + /// + /// This is intended for tests only. + @visibleForTesting + static void debugOverrideBrowserLanguages(List? value) { + _browserLanguagesOverride = value; + } + + static List? _browserLanguagesOverride; + + @visibleForTesting static List parseBrowserLanguages() { // TODO(yjbanov): find a solution for IE - final List? languages = domWindow.navigator.languages; + final List? languages = _browserLanguagesOverride ?? domWindow.navigator.languages; if (languages == null || languages.isEmpty) { // To make it easier for the app code, let's not leave the locales list // empty. This way there's fewer corner cases for apps to handle. @@ -912,12 +925,14 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { final List locales = []; for (final String language in languages) { - final List parts = language.split('-'); - if (parts.length > 1) { - locales.add(ui.Locale(parts.first, parts.last)); - } else { - locales.add(ui.Locale(language)); - } + final DomLocale domLocale = DomLocale(language); + locales.add( + ui.Locale.fromSubtags( + languageCode: domLocale.language, + scriptCode: domLocale.script, + countryCode: domLocale.region, + ), + ); } assert(locales.isNotEmpty); diff --git a/engine/src/flutter/lib/web_ui/test/engine/locale_test.dart b/engine/src/flutter/lib/web_ui/test/engine/locale_test.dart index f0a2fceb306..0dcdac2ce2c 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/locale_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/locale_test.dart @@ -4,6 +4,7 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; void main() { @@ -60,4 +61,66 @@ void testMain() { isNot(const Locale.fromSubtags(languageCode: 'en', scriptCode: 'Latn').hashCode), ); }); + + test('DomLocale', () { + final locale1 = DomLocale('uk-UA'); + + expect(locale1.language, 'uk'); + expect(locale1.script, isNull); + expect(locale1.region, 'UA'); + expect(locale1.calendar, isNull); + expect(locale1.caseFirst, isNull); + expect(locale1.collation, isNull); + expect(locale1.hourCycle, isNull); + expect(locale1.numberingSystem, isNull); + expect(locale1.numeric, false); + + final locale2 = DomLocale('en-Latn-US'); + + expect(locale2.language, 'en'); + expect(locale2.script, 'Latn'); + expect(locale2.region, 'US'); + expect(locale2.calendar, isNull); + expect(locale2.caseFirst, isNull); + expect(locale2.collation, isNull); + expect(locale2.hourCycle, isNull); + expect(locale2.numberingSystem, isNull); + expect(locale2.numeric, false); + + final locale3 = DomLocale('de-Latn-DE-u-ca-gregory-kf-upper-co-dict-hc-h24-nu-latn-kn-true'); + + expect(locale3.language, 'de'); + expect(locale3.script, 'Latn'); + expect(locale3.region, 'DE'); + expect(locale3.calendar, 'gregory'); + expect(locale3.caseFirst, 'upper'); + expect(locale3.collation, 'dict'); + expect(locale3.hourCycle, 'h24'); + expect(locale3.numberingSystem, 'latn'); + expect(locale3.numeric, isTrue); + + final locale4 = DomLocale( + 'th', + DomLocaleOptions( + script: 'Thai', + region: 'TH', + calendar: 'buddhist', + caseFirst: 'lower', + collation: 'dict', + hourCycle: 'h12', + numberingSystem: 'thai', + numeric: true, + ), + ); + + expect(locale4.language, 'th'); + expect(locale4.script, 'Thai'); + expect(locale4.region, 'TH'); + expect(locale4.calendar, 'buddhist'); + expect(locale4.caseFirst, 'lower'); + expect(locale4.collation, 'dict'); + expect(locale4.hourCycle, 'h12'); + expect(locale4.numberingSystem, 'thai'); + expect(locale4.numeric, true); + }); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart index b7f77cfb6eb..ce5d8e73eb1 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/platform_dispatcher_test.dart @@ -430,6 +430,34 @@ void testMain() { }); }); + group('parseBrowserLanguages', () { + test('returns the default locale when no browser languages are present', () { + EnginePlatformDispatcher.debugOverrideBrowserLanguages([]); + addTearDown(() => EnginePlatformDispatcher.debugOverrideBrowserLanguages(null)); + + expect(EnginePlatformDispatcher.parseBrowserLanguages(), const [ui.Locale('en', 'US')]); + }); + + test('returns locales list parsed from browser languages', () { + EnginePlatformDispatcher.debugOverrideBrowserLanguages([ + 'uk-UA', + 'en', + 'ar-Arab-SA', + 'zh-Hant-HK', + 'de-DE', + ]); + addTearDown(() => EnginePlatformDispatcher.debugOverrideBrowserLanguages(null)); + + expect(EnginePlatformDispatcher.parseBrowserLanguages(), const [ + ui.Locale('uk', 'UA'), + ui.Locale('en'), + ui.Locale.fromSubtags(languageCode: 'ar', scriptCode: 'Arab', countryCode: 'SA'), + ui.Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'), + ui.Locale('de', 'DE'), + ]); + }); + }); + group('AT Focus Handler Integration', () { test('navigation focus handler is registered during initialization', () { final DomElement navButton = createDomHTMLButtonElement();