Skip to content

Commit

Permalink
Merge pull request #1 from rootsher/feature/i18n
Browse files Browse the repository at this point in the history
feat: implement i18n handlers
  • Loading branch information
rootsher authored Aug 13, 2022
2 parents a4f9117 + 1f7db9a commit ee27568
Show file tree
Hide file tree
Showing 11 changed files with 367 additions and 1 deletion.
21 changes: 21 additions & 0 deletions lib/i18n/date/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { format as fnsFormat } from "date-fns";
import { useCurrentLocale } from "../utils/useCurrentLocale";

export function format(
date: Date | number,
formatStr = "PP",
options: {
locale?: Locale;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
firstWeekContainsDate?: number;
useAdditionalWeekYearTokens?: boolean;
useAdditionalDayOfYearTokens?: boolean;
} = {}
) {
const locale = useCurrentLocale();

return fnsFormat(date, formatStr, {
locale,
...options,
});
}
19 changes: 19 additions & 0 deletions lib/i18n/date/formatDistance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { formatDistance as fnsFormatDistance } from "date-fns";
import { useCurrentLocale } from "../utils/useCurrentLocale";

export function formatDistance(
date: Date | number,
baseDate: Date | number,
options: {
includeSeconds?: boolean;
addSuffix?: boolean;
locale?: Locale;
} = {}
) {
const locale = useCurrentLocale();

return fnsFormatDistance(date, baseDate, {
locale,
...options,
});
}
20 changes: 20 additions & 0 deletions lib/i18n/date/formatDistanceStrict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { formatDistanceStrict as fnsFormatDistanceStrict } from "date-fns";
import { useCurrentLocale } from "../utils/useCurrentLocale";

export function formatDistanceStrict(
date: Date | number,
baseDate: Date | number,
options: {
addSuffix?: boolean;
unit?: "second" | "minute" | "hour" | "day" | "month" | "year";
roundingMethod?: "floor" | "ceil" | "round";
locale?: Locale;
} = {}
) {
const locale = useCurrentLocale();

return fnsFormatDistanceStrict(date, baseDate, {
locale,
...options,
});
}
18 changes: 18 additions & 0 deletions lib/i18n/date/formatRelative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { formatRelative as fnsFormatRelative } from "date-fns";
import { useCurrentLocale } from "../utils/useCurrentLocale";

export function formatRelative(
date: Date | number,
baseDate: Date | number,
options: {
locale?: Locale;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
} = {}
) {
const locale = useCurrentLocale();

return fnsFormatRelative(date, baseDate, {
locale,
...options,
});
}
102 changes: 102 additions & 0 deletions lib/i18n/i18n.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ReactElement } from "react";
import i18n from "i18next";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { initReactI18next } from "react-i18next";

import { I18nProvider, useCurrentLocale, useI18n } from "./i18n";

const EN = "en";
const PL = "pl";
const availableLanguages = {
[EN]: "en-US",
[PL]: "pl",
};
const resources = {
[EN]: { translation: { hello: "hello" } },
[PL]: { translation: { hello: "cześć" } },
};

const renderProvider = (ui: ReactElement) => render(ui, { wrapper: Provider });

describe("i18n()", () => {
it("should check default language", async () => {
// arrange
renderProvider(<LanguageSwitcher />);

// act - noop

// assert
await waitFor(() => {
expect(screen.getByRole("locale")).toHaveTextContent(
availableLanguages[EN]
);
});
});

it("should change language and translation", async () => {
// arrange
renderProvider(<LanguageSwitcher />);

// act
userEvent.click(screen.getByRole(PL));

// assert
await waitFor(() => {
expect(screen.getByRole("locale")).toHaveTextContent(
availableLanguages[PL]
);
expect(screen.getByRole("translation")).toHaveTextContent(
resources[PL].translation.hello
);
});
});

afterEach(cleanup);
});

function Provider({ children }: { children: ReactElement }) {
const i18nInstance = i18n.createInstance();

i18nInstance.use(initReactI18next).init({
fallbackLng: EN,
debug: false,
load: "languageOnly",
supportedLngs: Object.keys(availableLanguages),
resources,
});

return (
<I18nProvider
i18n={i18nInstance}
availableLanguages={availableLanguages}
>
{children}
</I18nProvider>
);
}

function LanguageSwitcher() {
const { i18n, supportedLanguages, t } = useI18n();
const locale = useCurrentLocale();

return (
<>
{locale && (
<>
<div role="locale">{locale.code}</div>
<div role="translation">{t("hello")}</div>
</>
)}
{supportedLanguages.map((locale) => (
<button
role={locale}
onClick={() => i18n.changeLanguage(locale)}
key={locale}
>
{locale}
</button>
))}
</>
);
}
5 changes: 5 additions & 0 deletions lib/i18n/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { useCurrentLocale } from "./utils/useCurrentLocale";
import { useI18n } from "./utils/useI18n";
import { I18nProvider } from "./i18nContext";

export { I18nProvider, useI18n, useCurrentLocale };
95 changes: 95 additions & 0 deletions lib/i18n/i18nContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
createContext,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { Locale } from "date-fns";
import { I18nextProvider } from "react-i18next";
import { i18n } from "i18next";

type I18nContextData = ReturnType<typeof useProviderI18n>;
type I18nState = {
locales: { [key: string]: Locale };
};

// i18n context
export const I18nContext = createContext<I18nContextData | null>(null);

// i18n provider
export function I18nProvider({
i18n: i18nInstance,
availableLanguages = {},
children,
}: {
i18n: i18n;
availableLanguages: { [key: string]: string };
children: ReactNode;
}) {
const value = useProviderI18n();
const { setLocale, locales } = value;

const loadLocale = useCallback(async (language: string) => {
const localeName = availableLanguages[language];

if (!localeName) {
throw new Error(`Locale "${localeName}" not supported`);
}

return (await import(`date-fns/locale/${localeName}`)).default;
}, []);

const onLoaded = useCallback(async () => {
const loadedLocales: { [key: string]: Locale } = {};

await Promise.all(
(i18nInstance.languages || []).map((language) =>
loadLocale(language).then(
(locale) => (loadedLocales[language] = locale)
)
)
);

setLocale(loadedLocales);
}, []);

const onLanguageChanged = useCallback(async () => {
if (!locales[i18nInstance.resolvedLanguage]) {
const locale = await loadLocale(i18nInstance.resolvedLanguage);

setLocale({
...locales,
[i18nInstance.resolvedLanguage]: locale,
});
}
}, []);

useEffect(() => {
// only for loading "fallbackLng"
onLoaded();

i18nInstance.on("loaded", onLoaded);
i18nInstance.on("languageChanged", onLanguageChanged);

return () => {
i18nInstance.off("loaded", onLoaded);
i18nInstance.off("languageChanged", onLanguageChanged);
};
}, [i18nInstance]);

return (
<I18nContext.Provider value={value}>
<I18nextProvider i18n={i18nInstance}>{children}</I18nextProvider>
</I18nContext.Provider>
);
}

export function useProviderI18n() {
const [locales, setLocale] = useState<I18nState["locales"]>({});

return {
locales,
setLocale,
};
}
22 changes: 22 additions & 0 deletions lib/i18n/utils/useCurrentLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isArray } from "lodash";

import { useI18n } from "./useI18n";

export function useCurrentLocale() {
const {
i18n: {
language,
options: { fallbackLng },
},
locales,
} = useI18n();
let fallback = "";

if (typeof fallbackLng === "string") {
fallback = fallbackLng;
} else if (isArray(fallbackLng) && typeof fallbackLng[0] === "string") {
fallback = fallbackLng[0];
}

return locales[language] || locales[fallback];
}
16 changes: 16 additions & 0 deletions lib/i18n/utils/useI18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";

import { I18nContext } from "../i18nContext";

export function useI18n() {
const i18nContext = useContext(I18nContext);
const { i18n, t } = useTranslation();
const supportedLanguages = i18n.options.supportedLngs || [];

if (!i18nContext) {
throw new Error("`useI18n` must be used inside I18nProvider");
}

return { ...i18nContext, supportedLanguages, i18n, t };
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rootsher/react-core",
"version": "0.1.4",
"version": "0.2.0",
"description": "Core library for React projects",
"main": "dist/lib/index.js",
"types": "dist/lib/index.d.ts",
Expand All @@ -15,10 +15,14 @@
"prepublish": "yarn build"
},
"dependencies": {
"@testing-library/user-event": "^14.4.2",
"date-fns": "^2.29.1",
"i18next": "^21.8.16",
"lodash": "^4.17.21",
"qs": "^6.10.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^11.18.3",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit ee27568

Please sign in to comment.