Add intl4x list format (#677)

* Add list format

* Add test

* Add readme and changelog

* Dart fix

* Add vm test

* Add license header to file

* Changes as per review

* Use web coverage
diff --git a/.github/workflows/health.yaml b/.github/workflows/health.yaml
index 6d77298..51b7227 100644
--- a/.github/workflows/health.yaml
+++ b/.github/workflows/health.yaml
@@ -6,3 +6,5 @@
 jobs:
   health:
     uses: dart-lang/ecosystem/.github/workflows/health.yaml@main
+    with:
+      coverage_web: true
diff --git a/pkgs/intl4x/CHANGELOG.md b/pkgs/intl4x/CHANGELOG.md
index aac3189..a28e814 100644
--- a/pkgs/intl4x/CHANGELOG.md
+++ b/pkgs/intl4x/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.2.0
+
+- Add list format.
+
 ## 0.1.0
 
 - Initial version.
diff --git a/pkgs/intl4x/README.md b/pkgs/intl4x/README.md
index c948fbf..1754861 100644
--- a/pkgs/intl4x/README.md
+++ b/pkgs/intl4x/README.md
@@ -14,7 +14,7 @@
 ## Status
 |   | Number format  | List format  | Date format  | Collation  | Display names |
 |---|:---:|:---:|:---:|:---:|:---:|
-| **ECMA402 (web)** | :heavy_check_mark: |   |   | :heavy_check_mark: |   |
+| **ECMA402 (web)** | :heavy_check_mark: | :heavy_check_mark: |   | :heavy_check_mark: |   |
 | **ICU4X (web/native)**  |   |   |   |   |   | 
 
 
diff --git a/pkgs/intl4x/lib/intl4x.dart b/pkgs/intl4x/lib/intl4x.dart
index c2cfc45..a5315e6 100644
--- a/pkgs/intl4x/lib/intl4x.dart
+++ b/pkgs/intl4x/lib/intl4x.dart
@@ -8,8 +8,10 @@
 import 'src/data.dart';
 import 'src/ecma/ecma_policy.dart';
 import 'src/ecma/ecma_stub.dart' if (dart.library.js) 'src/ecma/ecma_web.dart';
+import 'src/list_format/list_format.dart';
+import 'src/list_format/list_format_impl.dart';
+import 'src/list_format/list_format_options.dart';
 import 'src/locale.dart';
-import 'src/number_format/number_format.dart';
 import 'src/number_format/number_format_impl.dart';
 import 'src/options.dart';
 
@@ -49,6 +51,11 @@
         NumberFormatImpl.build(currentLocale, localeMatcher, ecmaPolicy),
       );
 
+  ListFormat listFormat([ListFormatOptions? options]) => ListFormat(
+        options ?? const ListFormatOptions(),
+        ListFormatImpl.build(currentLocale, localeMatcher, ecmaPolicy),
+      );
+
   /// Construct an [Intl] instance providing the current [currentLocale] and the
   /// [ecmaPolicy] defining which locales should fall back to the browser
   /// provided functions.
diff --git a/pkgs/intl4x/lib/list_format.dart b/pkgs/intl4x/lib/list_format.dart
new file mode 100644
index 0000000..f67fa01
--- /dev/null
+++ b/pkgs/intl4x/lib/list_format.dart
@@ -0,0 +1,6 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+export 'src/list_format/list_format.dart';
+export 'src/list_format/list_format_options.dart';
diff --git a/pkgs/intl4x/lib/number_format.dart b/pkgs/intl4x/lib/number_format.dart
index 971782f..e977cfc 100644
--- a/pkgs/intl4x/lib/number_format.dart
+++ b/pkgs/intl4x/lib/number_format.dart
@@ -2,4 +2,5 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+export 'src/number_format/number_format.dart';
 export 'src/number_format/number_format_options.dart';
diff --git a/pkgs/intl4x/lib/src/list_format/list_format.dart b/pkgs/intl4x/lib/src/list_format/list_format.dart
new file mode 100644
index 0000000..786d6e6
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../options.dart';
+import '../test_checker.dart';
+import 'list_format_impl.dart';
+import 'list_format_options.dart';
+
+class ListFormat {
+  final ListFormatOptions _options;
+  final ListFormatImpl _listFormatImpl;
+
+  const ListFormat(this._options, this._listFormatImpl);
+
+  /// Locale-dependant concatenation of lists, for example in `en-US` locale:
+  /// ```dart
+  /// format(['A', 'B', 'C']) == 'A, B, and C'
+  /// ```
+  String format(
+    List<String> list, {
+    LocaleMatcher localeMatcher = LocaleMatcher.bestfit,
+    Type type = Type.conjunction,
+    ListStyle style = ListStyle.long,
+  }) {
+    if (isInTest) {
+      return '${list.join(', ')}//${_listFormatImpl.locale}';
+    } else {
+      return _listFormatImpl.formatImpl(list, _options);
+    }
+  }
+}
diff --git a/pkgs/intl4x/lib/src/list_format/list_format_4x.dart b/pkgs/intl4x/lib/src/list_format/list_format_4x.dart
new file mode 100644
index 0000000..e8f833b
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format_4x.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../locale.dart';
+import 'list_format_impl.dart';
+import 'list_format_options.dart';
+
+ListFormatImpl getListFormatter4X(Locale locale) => ListFormat4X(locale);
+
+class ListFormat4X extends ListFormatImpl {
+  ListFormat4X(super.locale);
+
+  @override
+  String formatImpl(List<String> list, ListFormatOptions options) {
+    throw UnimplementedError('Insert diplomat bindings here');
+  }
+}
diff --git a/pkgs/intl4x/lib/src/list_format/list_format_ecma.dart b/pkgs/intl4x/lib/src/list_format/list_format_ecma.dart
new file mode 100644
index 0000000..3170ef8
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format_ecma.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:js/js.dart';
+import 'package:js/js_util.dart';
+
+import '../locale.dart';
+import '../options.dart';
+import '../utils.dart';
+import 'list_format_impl.dart';
+import 'list_format_options.dart';
+
+ListFormatImpl? getListFormatterECMA(
+  Locale locale,
+  LocaleMatcher localeMatcher,
+) =>
+    _ListFormatECMA.tryToBuild(locale, localeMatcher);
+
+@JS('Intl.ListFormat')
+class ListFormatJS {
+  external factory ListFormatJS([List<String> locale, Object options]);
+  external String format(List<String> list);
+}
+
+@JS('Intl.ListFormat.supportedLocalesOf')
+external List<String> supportedLocalesOfJS(
+  List<String> listOfLocales, [
+  Object options,
+]);
+
+class _ListFormatECMA extends ListFormatImpl {
+  _ListFormatECMA(super.locales);
+
+  static ListFormatImpl? tryToBuild(
+    Locale locale,
+    LocaleMatcher localeMatcher,
+  ) {
+    final supportedLocales = supportedLocalesOf(locale, localeMatcher);
+    return supportedLocales.isNotEmpty
+        ? _ListFormatECMA(supportedLocales.first)
+        : null;
+  }
+
+  static List<String> supportedLocalesOf(
+    String locale,
+    LocaleMatcher localeMatcher,
+  ) {
+    final o = newObject<Object>();
+    setProperty(o, 'localeMatcher', localeMatcher.jsName);
+    return List.from(supportedLocalesOfJS([localeToJsFormat(locale)], o));
+  }
+
+  @override
+  String formatImpl(List<String> list, ListFormatOptions options) {
+    return ListFormatJS([localeToJsFormat(locale)], options.toJsOptions())
+        .format(list);
+  }
+}
+
+extension on ListFormatOptions {
+  Object toJsOptions() {
+    final o = newObject<Object>();
+    setProperty(o, 'localeMatcher', localeMatcher.jsName);
+    setProperty(o, 'type', type.name);
+    setProperty(o, 'style', style.name);
+    return o;
+  }
+}
diff --git a/pkgs/intl4x/lib/src/list_format/list_format_impl.dart b/pkgs/intl4x/lib/src/list_format/list_format_impl.dart
new file mode 100644
index 0000000..1192ea4
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format_impl.dart
@@ -0,0 +1,33 @@
+// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../../ecma_policy.dart';
+import '../ecma/ecma_policy.dart';
+import '../locale.dart';
+import '../options.dart';
+import '../utils.dart';
+import 'list_format_4x.dart';
+import 'list_format_options.dart';
+import 'list_format_stub.dart' if (dart.library.js) 'list_format_ecma.dart';
+
+abstract class ListFormatImpl {
+  final Locale locale;
+
+  ListFormatImpl(this.locale);
+
+  String formatImpl(List<String> list, ListFormatOptions options);
+
+  factory ListFormatImpl.build(
+    Locale locales,
+    LocaleMatcher localeMatcher,
+    EcmaPolicy ecmaPolicy,
+  ) =>
+      buildFormatter(
+        locales,
+        localeMatcher,
+        ecmaPolicy,
+        getListFormatterECMA,
+        getListFormatter4X,
+      );
+}
diff --git a/pkgs/intl4x/lib/src/list_format/list_format_options.dart b/pkgs/intl4x/lib/src/list_format/list_format_options.dart
new file mode 100644
index 0000000..f8faa3e
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format_options.dart
@@ -0,0 +1,42 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../options.dart';
+
+class ListFormatOptions {
+  final Type type;
+  final ListStyle style;
+  final LocaleMatcher localeMatcher;
+
+  const ListFormatOptions({
+    this.type = Type.conjunction,
+    this.style = ListStyle.long,
+    this.localeMatcher = LocaleMatcher.bestfit,
+  });
+}
+
+/// Indicates the type of grouping.
+enum Type {
+  /// For "and"-based grouping of the list items: "A, B, and C".
+  conjunction,
+
+  /// For "or"-based grouping of the list items: "A, B, or C".
+  disjunction,
+
+  /// Grouping the list items as a unit: "A, B, C".
+  unit;
+}
+
+/// Indicates the grouping style (for example, whether list separators and
+/// conjunctions are included).
+enum ListStyle {
+  /// Example: "A, B, and C".
+  long,
+
+  /// Example: "A, B, C".
+  short,
+
+  /// Example: "A B C".
+  narrow;
+}
diff --git a/pkgs/intl4x/lib/src/list_format/list_format_stub.dart b/pkgs/intl4x/lib/src/list_format/list_format_stub.dart
new file mode 100644
index 0000000..931ee5c
--- /dev/null
+++ b/pkgs/intl4x/lib/src/list_format/list_format_stub.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../locale.dart';
+import '../options.dart';
+import 'list_format_impl.dart';
+
+ListFormatImpl? getListFormatterECMA(
+  Locale locale,
+  LocaleMatcher localeMatcher,
+) =>
+    throw UnimplementedError('Cannot use ECMA outside of web environments.');
diff --git a/pkgs/intl4x/pubspec.yaml b/pkgs/intl4x/pubspec.yaml
index cdcd527..4f38c35 100644
--- a/pkgs/intl4x/pubspec.yaml
+++ b/pkgs/intl4x/pubspec.yaml
@@ -1,7 +1,7 @@
 name: intl4x
 description: >-
   A lightweight modular library for internationalization (i18n) functionality.
-version: 0.1.0
+version: 0.2.0
 repository: https://github.com/dart-lang/i18n/tree/main/pkgs/intl4x
 
 environment:
diff --git a/pkgs/intl4x/test/ecma/list_format_test.dart b/pkgs/intl4x/test/ecma/list_format_test.dart
new file mode 100644
index 0000000..b37a76e
--- /dev/null
+++ b/pkgs/intl4x/test/ecma/list_format_test.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn('browser')
+library;
+
+import 'package:intl4x/ecma_policy.dart';
+import 'package:intl4x/intl4x.dart';
+import 'package:intl4x/src/list_format/list_format_options.dart';
+import 'package:test/test.dart';
+
+import '../utils.dart';
+
+void main() {
+  group('List style options', () {
+    final list = ['A', 'B', 'C'];
+    final intl = Intl(defaultLocale: 'en_US');
+    testWithFormatting('long', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(style: ListStyle.long));
+      expect(listFormat.format(list), 'A, B, and C');
+    });
+    testWithFormatting('short', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(style: ListStyle.short));
+      expect(listFormat.format(list), 'A, B, & C');
+    });
+    testWithFormatting('narrow', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(style: ListStyle.narrow));
+      expect(listFormat.format(list), 'A, B, C');
+    });
+  });
+
+  group('List type options', () {
+    final list = ['A', 'B', 'C'];
+    final intl = Intl(defaultLocale: 'en_US');
+    testWithFormatting('long', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(type: Type.conjunction));
+      expect(listFormat.format(list), 'A, B, and C');
+    });
+    testWithFormatting('short', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(type: Type.disjunction));
+      expect(listFormat.format(list), 'A, B, or C');
+    });
+    testWithFormatting('narrow', () {
+      final listFormat =
+          intl.listFormat(const ListFormatOptions(type: Type.unit));
+      expect(listFormat.format(list), 'A, B, C');
+    });
+  });
+
+  group('List style and type combinations', () {
+    final list = ['A', 'B', 'C'];
+    final intl = Intl(ecmaPolicy: const AlwaysEcma(), defaultLocale: 'en_US');
+    testWithFormatting('long', () {
+      final formatter = intl.listFormat(const ListFormatOptions(
+          style: ListStyle.narrow, type: Type.conjunction));
+      expect(formatter.format(list), 'A, B, C');
+    });
+    testWithFormatting('short', () {
+      final formatter = intl.listFormat(
+          const ListFormatOptions(style: ListStyle.short, type: Type.unit));
+      expect(formatter.format(list), 'A, B, C');
+    });
+  });
+}
diff --git a/pkgs/intl4x/test/icu4x/list_format_test.dart b/pkgs/intl4x/test/icu4x/list_format_test.dart
new file mode 100644
index 0000000..bea93dc
--- /dev/null
+++ b/pkgs/intl4x/test/icu4x/list_format_test.dart
@@ -0,0 +1,29 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn('vm')
+library;
+
+import 'package:intl4x/intl4x.dart';
+import 'package:intl4x/list_format.dart';
+import 'package:test/test.dart';
+
+import '../utils.dart';
+
+void main() {
+  final list = ['A', 'B', 'C'];
+  test('Does not compare in tests', () {
+    final locale = 'de_DE';
+    final listFormatGerman = Intl(defaultLocale: locale)
+        .listFormat(const ListFormatOptions(style: ListStyle.long));
+    expect(listFormatGerman.format(list), '${list.join(', ')}//$locale');
+  });
+
+  testWithFormatting('long', () {
+    final intl = Intl(defaultLocale: 'en_US');
+    final listFormat =
+        intl.listFormat(const ListFormatOptions(style: ListStyle.long));
+    expect(() => listFormat.format(list), throwsA(isA<UnimplementedError>()));
+  });
+}