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..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" @@ -40,7 +41,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..5dc265f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,5 @@ 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 type { PropertyDescriptions } from './use-controllable-state/interfaces'; diff --git a/src/internal/locale/__tests__/merge-locales.test.ts b/src/internal/locale/__tests__/merge-locales.test.ts new file mode 100644 index 0000000..61fdb99 --- /dev/null +++ b/src/internal/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/internal/locale/__tests__/normalize-locale.test.ts b/src/internal/locale/__tests__/normalize-locale.test.ts new file mode 100644 index 0000000..4b3c64b --- /dev/null +++ b/src/internal/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/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(); + }); +}); diff --git a/src/internal/locale/__tests__/utils/intl-polyfill.ts b/src/internal/locale/__tests__/utils/intl-polyfill.ts new file mode 100644 index 0000000..d231bcd --- /dev/null +++ b/src/internal/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/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/internal/locale/merge-locales.ts b/src/internal/locale/merge-locales.ts new file mode 100644 index 0000000..a2522c3 --- /dev/null +++ b/src/internal/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; +} diff --git a/src/internal/locale/normalize-locale.ts b/src/internal/locale/normalize-locale.ts new file mode 100644 index 0000000..ac131f5 --- /dev/null +++ b/src/internal/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 '../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; +} diff --git a/src/internal/locale/normalize-start-of-week.ts b/src/internal/locale/normalize-start-of-week.ts new file mode 100644 index 0000000..3e8f45b --- /dev/null +++ b/src/internal/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; +}