diff --git a/.changeset/proud-pillows-provide.md b/.changeset/proud-pillows-provide.md new file mode 100644 index 0000000000..d5ac90fa6d --- /dev/null +++ b/.changeset/proud-pillows-provide.md @@ -0,0 +1,14 @@ +--- +'@sumup-oss/circuit-ui': minor +--- + +Added default translations for labels that don't change with the usage of the component. Translations are included for all locales where SumUp operates, namely `bg-BG`, `cs-CZ`, `da-DK`, `de-AT`, `de-CH`, `de-DE`, `de-LU`, `el-CY`, `el-GR`, `en-AU`, `en-GB`, `en-IE`, `en-MT`, `en-US`, `es-CL`, `es-CO`, `es-ES`, `es-MX`, `es-PE`, `es-US`, `et-EE`, `fi-FI`, `fr-BE`, `fr-CH`, `fr-FR`, `fr-LU`, `hr-HR`, `hu-HU`, `it-CH`, `it-IT`, `lt-LT`, `lv-LV`, `nb-NO`, `nl-BE`, `nl-NL`, `pl-PL`, `pt-BR`, `pt-PT`, `ro-RO`, `sk-SK`, `sl-SI`, and `sv-SE`. The current locale is determined based on the `locale` prop or the `navigator.language` API in environments that support it. If no supported locale is found, `en-US` is used as a fallback. + +The following component props are now optional: + +- Button, IconButton: `loadingLabel` +- Calendar: `prevMonthButtonLabel`, `nextMonthButtonLabel` +- DateInput: `yearInputLabel`, `monthInputLabel`, `dayInputLabel`, `openCalendarButtonLabel`, `closeCalendarButtonLabel`, `applyDateButtonLabel`, `clearDateButtonLabel` +- Toggletip: `closeButtonLabel` + +We'll add default translations to more components in the future. diff --git a/packages/circuit-ui/components/Button/Button.stories.tsx b/packages/circuit-ui/components/Button/Button.stories.tsx index 7059e29324..098453c4eb 100644 --- a/packages/circuit-ui/components/Button/Button.stories.tsx +++ b/packages/circuit-ui/components/Button/Button.stories.tsx @@ -110,6 +110,5 @@ export const Loading = (args: ButtonProps) => { }; Loading.args = { - loadingLabel: 'Loading', isLoading: true, }; diff --git a/packages/circuit-ui/components/Button/IconButton.stories.tsx b/packages/circuit-ui/components/Button/IconButton.stories.tsx index f4a55c6f2b..40be82e882 100644 --- a/packages/circuit-ui/components/Button/IconButton.stories.tsx +++ b/packages/circuit-ui/components/Button/IconButton.stories.tsx @@ -90,7 +90,6 @@ export const Loading = (args: IconButtonProps) => { }; Loading.args = { - loadingLabel: 'Loading', isLoading: true, icon: Plus, }; diff --git a/packages/circuit-ui/components/Button/base.spec.tsx b/packages/circuit-ui/components/Button/base.spec.tsx index 72c28072c1..5000e0b489 100644 --- a/packages/circuit-ui/components/Button/base.spec.tsx +++ b/packages/circuit-ui/components/Button/base.spec.tsx @@ -53,7 +53,7 @@ describe('Button', () => { }); it('should render loading button with loading label', () => { - const loadingLabel = 'Loading'; + const loadingLabel = 'Submitting form'; render(, - ); + const { container } = render(); const actual = await axe(container); expect(actual).toHaveNoViolations(); }); it('should have aria-busy and aria-live for a loading button', () => { - render( ), diff --git a/packages/circuit-ui/components/Toggletip/Toggletip.tsx b/packages/circuit-ui/components/Toggletip/Toggletip.tsx index bb2ba05b0c..f5f547d54e 100644 --- a/packages/circuit-ui/components/Toggletip/Toggletip.tsx +++ b/packages/circuit-ui/components/Toggletip/Toggletip.tsx @@ -49,8 +49,11 @@ import { CloseButton } from '../CloseButton/index.js'; import { Headline } from '../Headline/index.js'; import { Body } from '../Body/index.js'; import { Button, type ButtonProps } from '../Button/index.js'; +import { useI18n } from '../../hooks/useI18n/useI18n.js'; +import type { Locale } from '../../util/i18n.js'; import classes from './Toggletip.module.css'; +import { translations } from './translations/index.js'; export interface ToggletipReferenceProps { 'id': string; @@ -84,7 +87,7 @@ export interface ToggletipProps extends HTMLAttributes { /** * Label for the toggletip's close button. */ - closeButtonLabel: string; + closeButtonLabel?: string; /** * Whether the toggletip is initially open. Default: 'false'. */ @@ -105,11 +108,18 @@ export interface ToggletipProps extends HTMLAttributes { * Default: 12. */ offset?: number | { mainAxis?: number; crossAxis?: number }; + /** + * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) + * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`. + * When passing an array, the first supported locale is used. + * Defaults to `navigator.language` in supported environments. + */ + locale?: Locale; } export const Toggletip = forwardRef( - ( - { + (props, ref) => { + const { defaultOpen = false, placement: defaultPlacement = 'top', offset = 12, @@ -120,10 +130,9 @@ export const Toggletip = forwardRef( closeButtonLabel, className, style, - ...props - }, - ref, - ) => { + locale, + ...rest + } = useI18n(props, translations); const zIndex = useStackContext(); const isMobile = useMedia('(max-width: 479px)'); const arrowRef = useRef(null); @@ -244,7 +253,7 @@ export const Toggletip = forwardRef( {/* eslint-disable jsx-a11y/no-autofocus */} {/* @ts-expect-error "Expression produces a union type that is too complex to represent" */} ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore import.meta.glob is supported by Vite + import.meta.glob('./*.json', { + eager: true, + }), +); diff --git a/packages/circuit-ui/components/Toggletip/translations/it-CH.json b/packages/circuit-ui/components/Toggletip/translations/it-CH.json new file mode 100644 index 0000000000..73c3efb158 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/it-CH.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Chiudi" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/it-IT.json b/packages/circuit-ui/components/Toggletip/translations/it-IT.json new file mode 100644 index 0000000000..73c3efb158 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/it-IT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Chiudi" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/lt-LT.json b/packages/circuit-ui/components/Toggletip/translations/lt-LT.json new file mode 100644 index 0000000000..b55cf74468 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/lt-LT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Uždaryti" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/lv-LV.json b/packages/circuit-ui/components/Toggletip/translations/lv-LV.json new file mode 100644 index 0000000000..486fc0ee7a --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/lv-LV.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Aizvērt" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/nb-NO.json b/packages/circuit-ui/components/Toggletip/translations/nb-NO.json new file mode 100644 index 0000000000..72f78d9c6a --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/nb-NO.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Lukk" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/nl-BE.json b/packages/circuit-ui/components/Toggletip/translations/nl-BE.json new file mode 100644 index 0000000000..538d49ed54 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/nl-BE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sluit" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/nl-NL.json b/packages/circuit-ui/components/Toggletip/translations/nl-NL.json new file mode 100644 index 0000000000..f2a9127e10 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/nl-NL.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Sluiten" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/pl-PL.json b/packages/circuit-ui/components/Toggletip/translations/pl-PL.json new file mode 100644 index 0000000000..a1cb3d0112 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/pl-PL.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zamknij" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/pt-BR.json b/packages/circuit-ui/components/Toggletip/translations/pt-BR.json new file mode 100644 index 0000000000..12ce2fa58f --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fechar" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/pt-PT.json b/packages/circuit-ui/components/Toggletip/translations/pt-PT.json new file mode 100644 index 0000000000..12ce2fa58f --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/pt-PT.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Fechar" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/ro-RO.json b/packages/circuit-ui/components/Toggletip/translations/ro-RO.json new file mode 100644 index 0000000000..58d8225420 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/ro-RO.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Închide" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/sk-SK.json b/packages/circuit-ui/components/Toggletip/translations/sk-SK.json new file mode 100644 index 0000000000..73e63be91a --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/sk-SK.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zatvoriť" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/sl-SI.json b/packages/circuit-ui/components/Toggletip/translations/sl-SI.json new file mode 100644 index 0000000000..364b505566 --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/sl-SI.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Zapri" +} diff --git a/packages/circuit-ui/components/Toggletip/translations/sv-SE.json b/packages/circuit-ui/components/Toggletip/translations/sv-SE.json new file mode 100644 index 0000000000..2d332d9d2a --- /dev/null +++ b/packages/circuit-ui/components/Toggletip/translations/sv-SE.json @@ -0,0 +1,3 @@ +{ + "closeButtonLabel": "Stäng" +} diff --git a/packages/circuit-ui/components/Tooltip/Tooltip.tsx b/packages/circuit-ui/components/Tooltip/Tooltip.tsx index e518f5d419..72866874e2 100644 --- a/packages/circuit-ui/components/Tooltip/Tooltip.tsx +++ b/packages/circuit-ui/components/Tooltip/Tooltip.tsx @@ -211,18 +211,17 @@ export const Tooltip = forwardRef( return undefined; }, [state, update]); - if (process.env.NODE_ENV !== 'production' && !type) { - throw new CircuitError('Tooltip', 'The `type` prop is required.'); - } + if (process.env.NODE_ENV !== 'production') { + if (!type) { + throw new CircuitError('Tooltip', 'The `type` prop is required.'); + } - if ( - process.env.NODE_ENV !== 'production' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'Tooltip', - 'The `label` prop is missing or invalid.', - ); + if (!isSufficientlyLabelled(label)) { + throw new AccessibilityError( + 'Tooltip', + 'The `label` prop is missing or invalid.', + ); + } } const referenceProps = { [ariaAttributeName]: tooltipId }; diff --git a/packages/circuit-ui/hooks/useI18n/useI18n.spec.ts b/packages/circuit-ui/hooks/useI18n/useI18n.spec.ts new file mode 100644 index 0000000000..151c6a945c --- /dev/null +++ b/packages/circuit-ui/hooks/useI18n/useI18n.spec.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { renderHook } from '../../util/test-utils.js'; +import type { Locale } from '../../util/i18n.js'; + +import { useI18n } from './useI18n.js'; + +type Props = { + locale?: Locale; + greeting?: string; + foo?: string; + onFoo?: () => void; +}; + +describe('useI18n', () => { + const translations = { + 'en-US': { greeting: 'Hello' }, + 'de-DE': { greeting: 'Hallo' }, + }; + + it('should return translations for the provided locale', () => { + const props: Props = { locale: 'de-DE' }; + const { result } = renderHook(() => useI18n(props, translations)); + expect(result.current.locale).toBe('de-DE'); + expect(result.current.greeting).toBe('Hallo'); + }); + + it('should return the custom translation when provided', () => { + const props: Props = { greeting: 'Salut' }; + const { result } = renderHook(() => useI18n(props, translations)); + expect(result.current.greeting).toBe('Salut'); + }); + + it('should forward all other props', () => { + const props: Props = { foo: 'bar', onFoo: vi.fn() }; + const { result } = renderHook(() => useI18n(props, translations)); + expect(result.current).toEqual(expect.objectContaining(props)); + }); +}); diff --git a/packages/circuit-ui/hooks/useI18n/useI18n.ts b/packages/circuit-ui/hooks/useI18n/useI18n.ts new file mode 100644 index 0000000000..fa540b58ae --- /dev/null +++ b/packages/circuit-ui/hooks/useI18n/useI18n.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { + findSupportedLocale, + type Locale, + type Translations, +} from '../../util/i18n.js'; +import { useLocale } from '../useLocale/useLocale.js'; + +type I18nProps = { + [key in Key]: string; +} & { + locale: Locale; +}; + +export function useI18n< + Props extends Partial>, + Key extends string | number | symbol, +>(props: Props, translations: Translations): Props & I18nProps { + const locale = useLocale(props.locale); + + const supportedLocale = findSupportedLocale(locale); + const strings = translations[supportedLocale] || {}; + const keys = Object.keys(strings) as Key[]; + + const translatedProps = keys.reduce( + (acc, key) => { + acc[key] = props[key] || strings[key]; + return acc; + }, + {} as Record, + ); + + return { ...props, ...translatedProps, locale }; +} diff --git a/packages/circuit-ui/util/i18n.spec.ts b/packages/circuit-ui/util/i18n.spec.ts index dc037666f6..aac994eba5 100644 --- a/packages/circuit-ui/util/i18n.spec.ts +++ b/packages/circuit-ui/util/i18n.spec.ts @@ -22,7 +22,11 @@ import { type MockInstance, } from 'vitest'; -import { FALLBACK_LOCALE, getDefaultLocale } from './i18n.js'; +import { + FALLBACK_LOCALE, + findSupportedLocale, + getDefaultLocale, +} from './i18n.js'; describe('i18n', () => { describe('getDefaultLocale', () => { @@ -65,4 +69,30 @@ describe('i18n', () => { expect(actual).toEqual(FALLBACK_LOCALE); }); }); + + describe('findSupportedLocale', () => { + it('should match a full locale', () => { + const locale = 'de-DE'; + const actual = findSupportedLocale(locale); + expect(actual).toBe('de-DE'); + }); + + it('should match a partial locale', () => { + const locale = 'de'; + const actual = findSupportedLocale(locale); + expect(actual).toBe('de-AT'); + }); + + it('should return the first supported locale', () => { + const locale = ['zh-CH', 'en-US', 'de-DE']; + const actual = findSupportedLocale(locale); + expect(actual).toBe('en-US'); + }); + + it('should fallback to the default locale if the provided locale is not supported', () => { + const locale = 'zh-CH'; + const actual = findSupportedLocale(locale); + expect(actual).toBe(FALLBACK_LOCALE); + }); + }); }); diff --git a/packages/circuit-ui/util/i18n.ts b/packages/circuit-ui/util/i18n.ts index ac73ec8faa..c2a6770595 100644 --- a/packages/circuit-ui/util/i18n.ts +++ b/packages/circuit-ui/util/i18n.ts @@ -13,10 +13,63 @@ * limitations under the License. */ -export type Locale = string | string[]; +import { isString } from './type-check.js'; export const FALLBACK_LOCALE = 'en-US'; +export const SUPPORTED_LOCALES = [ + 'bg-BG', + 'cs-CZ', + 'da-DK', + 'de-AT', + 'de-CH', + 'de-DE', + 'de-LU', + 'el-CY', + 'el-GR', + 'en-AU', + 'en-GB', + 'en-IE', + 'en-MT', + 'en-US', + 'es-CL', + 'es-CO', + 'es-ES', + 'es-MX', + 'es-PE', + 'es-US', + 'et-EE', + 'fi-FI', + 'fr-BE', + 'fr-CH', + 'fr-FR', + 'fr-LU', + 'hr-HR', + 'hu-HU', + 'it-CH', + 'it-IT', + 'lt-LT', + 'lv-LV', + 'nb-NO', + 'nl-BE', + 'nl-NL', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ro-RO', + 'sk-SK', + 'sl-SI', + 'sv-SE', +] as const; + +export type Locale = string | string[]; +type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +export type Translations = Record< + SupportedLocale, + Record +>; + /** * Returns the user's preferred locale(s) in browser-like environments. */ @@ -28,3 +81,67 @@ export function getDefaultLocale(): Locale { navigator.language || FALLBACK_LOCALE) as Locale; } + +/** + * Returns the first supported locale. + */ +export function findSupportedLocale(locale: Locale): SupportedLocale { + const locales = isString(locale) ? [locale] : locale; + + // eslint-disable-next-line no-restricted-syntax + for (const l of locales) { + const matcher = + locale.length === 5 + ? (value: string) => value === l + : (value: string) => value.startsWith(l); + + const match = SUPPORTED_LOCALES.find(matcher); + + if (match) { + return match; + } + } + + return FALLBACK_LOCALE; +} + +export function transformModulesToTranslations< + T extends Record, + Key extends string | number | symbol = keyof T, +>(modules: Record): Translations { + const translations = Object.entries(modules).reduce( + (acc, [importPath, strings]) => { + const matches = importPath.match(/[a-z]{2}-[A-Z]{2}/); + + // @ts-expect-error This environment variable is set by Vite. + if (import.meta.env.DEV && !matches) { + throw new Error( + `Failed to extract a locale from the import path: ${importPath}`, + ); + } + + // biome-ignore lint/style/noNonNullAssertion: + const locale = matches![0] as SupportedLocale; + + // @ts-expect-error This environment variable is set by Vite. + if (import.meta.env.DEV && !SUPPORTED_LOCALES.includes(locale)) { + throw new Error(`Unsupported locale: ${importPath}`); + } + + acc[locale] = strings as Record; + return acc; + }, + {} as Translations, + ); + + // @ts-expect-error This environment variable is set by Vite. + if (import.meta.env.DEV) { + SUPPORTED_LOCALES.forEach((locale) => { + if (!translations[locale]) { + throw new Error(`Missing translations for locale ${locale}`); + } + }); + } + + return translations; +}