From 5a8a7826aa9806091a7635db41de43178e161d54 Mon Sep 17 00:00:00 2001 From: Mael Kerichard Date: Mon, 24 Nov 2025 11:47:34 +0000 Subject: [PATCH 1/5] feat: add locale normalization and merging utilities Initially coming from https://github.com/cloudscape-design/components/tree/19cf57da0cefff1173ecf2d6457d84fb95b4a8df/src/internal/utils/locale --- package-lock.json | 14 +++- package.json | 3 +- src/index.ts | 3 +- src/locale/__tests__/merge-locales.test.ts | 16 ++++ src/locale/__tests__/normalize-locale.test.ts | 75 +++++++++++++++++++ src/locale/__tests__/utils/intl-polyfill.ts | 10 +++ src/locale/index.ts | 6 ++ src/locale/merge-locales.ts | 10 +++ src/locale/normalize-locale.ts | 43 +++++++++++ src/locale/normalize-start-of-week.ts | 10 +++ 10 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/locale/__tests__/merge-locales.test.ts create mode 100644 src/locale/__tests__/normalize-locale.test.ts create mode 100644 src/locale/__tests__/utils/intl-polyfill.ts create mode 100644 src/locale/index.ts create mode 100644 src/locale/merge-locales.ts create mode 100644 src/locale/normalize-locale.ts create mode 100644 src/locale/normalize-start-of-week.ts diff --git a/package-lock.json b/package-lock.json index 53b6f24..598a39e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "@cloudscape-design/component-toolkit", "version": "1.0.0-beta", "dependencies": { - "tslib": "^2.3.1" + "tslib": "^2.3.1", + "weekstart": "^2.0.0" }, "devDependencies": { "@cloudscape-design/browser-test-tools": "^3.0.0", @@ -15407,6 +15408,12 @@ "node": ">=12" } }, + "node_modules/weekstart": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/weekstart/-/weekstart-2.0.0.tgz", + "integrity": "sha512-HjYc14IQUwDcnGICuc8tVtqAd6EFpoAQMqgrqcNtWWZB+F1b7iTq44GzwM1qvnH4upFgbhJsaNHuK93NOFheSg==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -26700,6 +26707,11 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true }, + "weekstart": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/weekstart/-/weekstart-2.0.0.tgz", + "integrity": "sha512-HjYc14IQUwDcnGICuc8tVtqAd6EFpoAQMqgrqcNtWWZB+F1b7iTq44GzwM1qvnH4upFgbhJsaNHuK93NOFheSg==" + }, "whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", diff --git a/package.json b/package.json index 3fb31bf..651511b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "prepare": "husky" }, "dependencies": { - "tslib": "^2.3.1" + "tslib": "^2.3.1", + "weekstart": "^2.0.0" }, "devDependencies": { "@cloudscape-design/browser-test-tools": "^3.0.0", diff --git a/src/index.ts b/src/index.ts index 79fc595..cfe6c38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,6 @@ export { default as useContainerQuery } from './container-queries/use-container-query'; export { default as useControllableState } from './use-controllable-state/use-controllable-state'; -export type { PropertyDescriptions } from './use-controllable-state/interfaces'; export type { ContainerQueryEntry } from './container-queries/interfaces'; +export * from './locale'; +export type { PropertyDescriptions } from './use-controllable-state/interfaces'; diff --git a/src/locale/__tests__/merge-locales.test.ts b/src/locale/__tests__/merge-locales.test.ts new file mode 100644 index 0000000..61fdb99 --- /dev/null +++ b/src/locale/__tests__/merge-locales.test.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mergeLocales } from '../merge-locales'; + +test('should return the first locale if it is fully specified', () => { + expect(mergeLocales('en-US', 'fr-CA')).toEqual('en-US'); +}); + +test('should return the second locale if it extends the first', () => { + expect(mergeLocales('en', 'en-US')).toEqual('en-US'); +}); + +test('should return the first locale if the second is different', () => { + expect(mergeLocales('en', 'fr-CA')).toEqual('en'); +}); diff --git a/src/locale/__tests__/normalize-locale.test.ts b/src/locale/__tests__/normalize-locale.test.ts new file mode 100644 index 0000000..4b3c64b --- /dev/null +++ b/src/locale/__tests__/normalize-locale.test.ts @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeLocale } from '../normalize-locale'; +import setOptions from './utils/intl-polyfill'; + +function withDocumentLang(lang: string, callback: () => void) { + const htmlElement = document.querySelector('html'); + htmlElement!.setAttribute('lang', lang); + callback(); + htmlElement!.removeAttribute('lang'); +} + +describe('normalizeLocale', () => { + let consoleSpy: jest.SpyInstance; + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'warn'); + setOptions({ locale: 'en-US' }); + }); + + afterEach(() => { + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + test('should return the provided value', () => { + expect(normalizeLocale('DatePickerTest', 'en-US')).toBe('en-US'); + }); + + test('should extend provided value if it is short form', () => { + expect(normalizeLocale('DatePickerTest', 'en')).toBe('en-US'); + setOptions({ locale: 'en-GB' }); + expect(normalizeLocale('DatePickerTest', 'en')).toBe('en-GB'); + }); + + test('should not extend the provided value if it starts from different language', () => { + expect(normalizeLocale('DatePickerTest', 'fr')).toBe('fr'); + }); + + test('should replace underscores with dashes', () => { + expect(normalizeLocale('DatePickerTest', 'zh_CN')).toBe('zh-CN'); + }); + + test('should warn if the provided value is in invalid format', () => { + expect(normalizeLocale('DatePickerTest', 'not-locale')).toBe('en-US'); + expect(consoleSpy).toHaveBeenCalledWith( + '[AwsUi] [DatePickerTest] Invalid locale provided: not-locale. Falling back to default' + ); + consoleSpy.mockReset(); + }); + + test('should return document language by default', () => { + withDocumentLang('en', () => { + expect(normalizeLocale('DatePickerTest', null)).toBe('en-US'); + }); + }); + + test('should not extend document language with locale if they do not match', () => { + withDocumentLang('fr', () => { + expect(normalizeLocale('DatePickerTest', null)).toBe('fr'); + }); + }); + + test('should combine values from document lang and browser locale', () => { + setOptions({ locale: 'fr-CA' }); + withDocumentLang('fr', () => { + expect(normalizeLocale('DatePickerTest', null)).toBe('fr-CA'); + }); + }); + + test('should replace underscores with dashes in document lang', () => { + withDocumentLang('zh_CN', () => { + expect(normalizeLocale('DatePickerTest', null)).toBe('zh-CN'); + }); + }); +}); diff --git a/src/locale/__tests__/utils/intl-polyfill.ts b/src/locale/__tests__/utils/intl-polyfill.ts new file mode 100644 index 0000000..d231bcd --- /dev/null +++ b/src/locale/__tests__/utils/intl-polyfill.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// We use resolvedOptions to detect browser locale. This method allows us to override the value for testing. +export default function setResolvedOptions(newValue: { locale: string }): void { + const dateTimeFormat = new Intl.DateTimeFormat(newValue.locale); + const resolvedOptions = dateTimeFormat.resolvedOptions(); + + window.Intl.DateTimeFormat.prototype.resolvedOptions = () => ({ ...resolvedOptions, ...newValue }); +} diff --git a/src/locale/index.ts b/src/locale/index.ts new file mode 100644 index 0000000..f678a43 --- /dev/null +++ b/src/locale/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { mergeLocales } from './merge-locales'; +export { normalizeLocale } from './normalize-locale'; +export { normalizeStartOfWeek, DayIndex } from './normalize-start-of-week'; diff --git a/src/locale/merge-locales.ts b/src/locale/merge-locales.ts new file mode 100644 index 0000000..8a61c77 --- /dev/null +++ b/src/locale/merge-locales.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function mergeLocales(locale: string, fullLocale: string) { + const isShort = locale.length === 2; + if (isShort && fullLocale.indexOf(locale) === 0) { + return fullLocale; + } + return locale; +} \ No newline at end of file diff --git a/src/locale/normalize-locale.ts b/src/locale/normalize-locale.ts new file mode 100644 index 0000000..1fe9b0d --- /dev/null +++ b/src/locale/normalize-locale.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { warnOnce } from '../internal/logging'; + +import { mergeLocales } from './merge-locales'; + +export function normalizeLocale(component: string, locale: string | null): string { + locale = checkLocale(component, locale); + const browserLocale = getBrowserLocale(); + if (locale) { + return mergeLocales(locale, browserLocale); + } + const htmlLocale = checkLocale(component, getHtmlElement()?.getAttribute('lang')); + if (htmlLocale) { + return mergeLocales(htmlLocale, browserLocale); + } + return browserLocale; +} + +function checkLocale(component: string, locale: string | null | undefined): string { + if (!locale || locale === '') { + return ''; + } + + // Support underscore-delimited locales + locale = locale && locale.replace(/^([a-z]{2})_/, '$1-'); + // Check that the value matches aa-BB pattern + // TODO: support full BCP 47 spec? + if (locale && !locale.match(/^[a-z]{2}(-[A-Z]{2})?$/)) { + warnOnce(component, `Invalid locale provided: ${locale}. Falling back to default`); + locale = ''; + } + return locale; +} + +function getHtmlElement() { + return typeof document !== 'undefined' ? document.querySelector('html') : null; +} + +function getBrowserLocale() { + return new Intl.DateTimeFormat().resolvedOptions().locale; +} \ No newline at end of file diff --git a/src/locale/normalize-start-of-week.ts b/src/locale/normalize-start-of-week.ts new file mode 100644 index 0000000..a5ac763 --- /dev/null +++ b/src/locale/normalize-start-of-week.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getWeekStartByLocale } from 'weekstart'; + +export type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export function normalizeStartOfWeek(startOfWeek: number | undefined, locale: string) { + return (typeof startOfWeek === 'number' ? startOfWeek % 7 : getWeekStartByLocale(locale)) as DayIndex; +} \ No newline at end of file From ed550f3ada0daeda85c349320296094ba96ee4a6 Mon Sep 17 00:00:00 2001 From: Mael Kerichard Date: Mon, 24 Nov 2025 11:48:37 +0000 Subject: [PATCH 2/5] style: lint --- src/locale/merge-locales.ts | 2 +- src/locale/normalize-locale.ts | 2 +- src/locale/normalize-start-of-week.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/locale/merge-locales.ts b/src/locale/merge-locales.ts index 8a61c77..a2522c3 100644 --- a/src/locale/merge-locales.ts +++ b/src/locale/merge-locales.ts @@ -7,4 +7,4 @@ export function mergeLocales(locale: string, fullLocale: string) { return fullLocale; } return locale; -} \ No newline at end of file +} diff --git a/src/locale/normalize-locale.ts b/src/locale/normalize-locale.ts index 1fe9b0d..d343087 100644 --- a/src/locale/normalize-locale.ts +++ b/src/locale/normalize-locale.ts @@ -40,4 +40,4 @@ function getHtmlElement() { function getBrowserLocale() { return new Intl.DateTimeFormat().resolvedOptions().locale; -} \ No newline at end of file +} diff --git a/src/locale/normalize-start-of-week.ts b/src/locale/normalize-start-of-week.ts index a5ac763..3e8f45b 100644 --- a/src/locale/normalize-start-of-week.ts +++ b/src/locale/normalize-start-of-week.ts @@ -7,4 +7,4 @@ export type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; export function normalizeStartOfWeek(startOfWeek: number | undefined, locale: string) { return (typeof startOfWeek === 'number' ? startOfWeek % 7 : getWeekStartByLocale(locale)) as DayIndex; -} \ No newline at end of file +} From 9cd34acdb0a0284d7c2b9fdf420e4083eacb563d Mon Sep 17 00:00:00 2001 From: Mael Kerichard Date: Wed, 26 Nov 2025 09:06:35 +0000 Subject: [PATCH 3/5] fix: imports --- src/index.ts | 6 +++++- src/locale/index.ts | 6 ------ 2 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 src/locale/index.ts diff --git a/src/index.ts b/src/index.ts index cfe6c38..3d4248e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,9 @@ export { default as useContainerQuery } from './container-queries/use-container- export { default as useControllableState } from './use-controllable-state/use-controllable-state'; export type { ContainerQueryEntry } from './container-queries/interfaces'; -export * from './locale'; export type { PropertyDescriptions } from './use-controllable-state/interfaces'; + +// Locale utils +export { mergeLocales } from './locale/merge-locales'; +export { normalizeLocale } from './locale/normalize-locale'; +export { normalizeStartOfWeek, DayIndex } from './locale/normalize-start-of-week'; diff --git a/src/locale/index.ts b/src/locale/index.ts deleted file mode 100644 index f678a43..0000000 --- a/src/locale/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -export { mergeLocales } from './merge-locales'; -export { normalizeLocale } from './normalize-locale'; -export { normalizeStartOfWeek, DayIndex } from './normalize-start-of-week'; From 124976b536c6cde514cda19ff1cd5dbd33deb7de Mon Sep 17 00:00:00 2001 From: Mael Kerichard Date: Wed, 26 Nov 2025 13:33:11 +0000 Subject: [PATCH 4/5] refactor: move locale utilities to internal API --- package.json | 1 + src/index.ts | 5 ----- src/{ => internal}/locale/__tests__/merge-locales.test.ts | 0 .../locale/__tests__/normalize-locale.test.ts | 0 src/{ => internal}/locale/__tests__/utils/intl-polyfill.ts | 0 src/internal/locale/index.ts | 6 ++++++ src/{ => internal}/locale/merge-locales.ts | 0 src/{ => internal}/locale/normalize-locale.ts | 2 +- src/{ => internal}/locale/normalize-start-of-week.ts | 0 9 files changed, 8 insertions(+), 6 deletions(-) rename src/{ => internal}/locale/__tests__/merge-locales.test.ts (100%) rename src/{ => internal}/locale/__tests__/normalize-locale.test.ts (100%) rename src/{ => internal}/locale/__tests__/utils/intl-polyfill.ts (100%) create mode 100644 src/internal/locale/index.ts rename src/{ => internal}/locale/merge-locales.ts (100%) rename src/{ => internal}/locale/normalize-locale.ts (96%) rename src/{ => internal}/locale/normalize-start-of-week.ts (100%) diff --git a/package.json b/package.json index 651511b..1766414 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "./internal": "./internal/index.js", "./internal/metrics": "./internal/metrics.js", "./internal/testing": "./internal/testing.js", + "./internal/locale": "./internal/locale.js", "./internal/analytics-metadata": "./internal/analytics-metadata/index.js", "./internal/analytics-metadata/utils": "./internal/analytics-metadata/utils.js", "./package.json": "./package.json" diff --git a/src/index.ts b/src/index.ts index 3d4248e..5dc265f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,3 @@ export { default as useControllableState } from './use-controllable-state/use-co export type { ContainerQueryEntry } from './container-queries/interfaces'; export type { PropertyDescriptions } from './use-controllable-state/interfaces'; - -// Locale utils -export { mergeLocales } from './locale/merge-locales'; -export { normalizeLocale } from './locale/normalize-locale'; -export { normalizeStartOfWeek, DayIndex } from './locale/normalize-start-of-week'; diff --git a/src/locale/__tests__/merge-locales.test.ts b/src/internal/locale/__tests__/merge-locales.test.ts similarity index 100% rename from src/locale/__tests__/merge-locales.test.ts rename to src/internal/locale/__tests__/merge-locales.test.ts diff --git a/src/locale/__tests__/normalize-locale.test.ts b/src/internal/locale/__tests__/normalize-locale.test.ts similarity index 100% rename from src/locale/__tests__/normalize-locale.test.ts rename to src/internal/locale/__tests__/normalize-locale.test.ts diff --git a/src/locale/__tests__/utils/intl-polyfill.ts b/src/internal/locale/__tests__/utils/intl-polyfill.ts similarity index 100% rename from src/locale/__tests__/utils/intl-polyfill.ts rename to src/internal/locale/__tests__/utils/intl-polyfill.ts diff --git a/src/internal/locale/index.ts b/src/internal/locale/index.ts new file mode 100644 index 0000000..0fb2143 --- /dev/null +++ b/src/internal/locale/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { mergeLocales } from './merge-locales'; +export { normalizeLocale } from './normalize-locale'; +export { DayIndex, normalizeStartOfWeek } from './normalize-start-of-week'; diff --git a/src/locale/merge-locales.ts b/src/internal/locale/merge-locales.ts similarity index 100% rename from src/locale/merge-locales.ts rename to src/internal/locale/merge-locales.ts diff --git a/src/locale/normalize-locale.ts b/src/internal/locale/normalize-locale.ts similarity index 96% rename from src/locale/normalize-locale.ts rename to src/internal/locale/normalize-locale.ts index d343087..ac131f5 100644 --- a/src/locale/normalize-locale.ts +++ b/src/internal/locale/normalize-locale.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { warnOnce } from '../internal/logging'; +import { warnOnce } from '../logging'; import { mergeLocales } from './merge-locales'; diff --git a/src/locale/normalize-start-of-week.ts b/src/internal/locale/normalize-start-of-week.ts similarity index 100% rename from src/locale/normalize-start-of-week.ts rename to src/internal/locale/normalize-start-of-week.ts From f716264a6ab376c76ea4eedd9bc62747fddec4b9 Mon Sep 17 00:00:00 2001 From: Mael Kerichard Date: Thu, 27 Nov 2025 15:40:27 +0000 Subject: [PATCH 5/5] test(locale): add unit tests for normalizeStartOfWeek function --- .../__tests__/normalize-start-of-week.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/internal/locale/__tests__/normalize-start-of-week.test.ts diff --git a/src/internal/locale/__tests__/normalize-start-of-week.test.ts b/src/internal/locale/__tests__/normalize-start-of-week.test.ts new file mode 100644 index 0000000..af3f998 --- /dev/null +++ b/src/internal/locale/__tests__/normalize-start-of-week.test.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeStartOfWeek } from '../normalize-start-of-week'; + +// Mock the weekstart module +jest.mock('weekstart', () => ({ + getWeekStartByLocale: jest.fn(), +})); + +import { getWeekStartByLocale } from 'weekstart'; + +const mockGetWeekStartByLocale = getWeekStartByLocale as jest.MockedFunction; + +describe('normalizeStartOfWeek', () => { + beforeEach(() => { + mockGetWeekStartByLocale.mockReset(); + }); + + describe('when startOfWeek is undefined', () => { + test('should call getWeekStartByLocale with the provided locale', () => { + mockGetWeekStartByLocale.mockReturnValue(0); + const result = normalizeStartOfWeek(undefined, 'en-US'); + + expect(mockGetWeekStartByLocale).toHaveBeenCalledWith('en-US'); + expect(mockGetWeekStartByLocale).toHaveBeenCalledTimes(1); + expect(result).toBe(0); + }); + + test('should return Sunday (0) for US locale', () => { + mockGetWeekStartByLocale.mockReturnValue(0); + expect(normalizeStartOfWeek(undefined, 'en-US')).toBe(0); + }); + + test('should return Monday (1) for French locale', () => { + mockGetWeekStartByLocale.mockReturnValue(1); + expect(normalizeStartOfWeek(undefined, 'fr-FR')).toBe(1); + }); + }); + + test('should prioritize provided number over locale', () => { + mockGetWeekStartByLocale.mockReturnValue(0); + expect(normalizeStartOfWeek(1, 'en-US')).toBe(1); + expect(mockGetWeekStartByLocale).not.toHaveBeenCalled(); + }); +});