diff --git a/apps/website/globals.d.ts b/apps/website/globals.d.ts index 2553ffbc13..18f423bf39 100644 --- a/apps/website/globals.d.ts +++ b/apps/website/globals.d.ts @@ -1,10 +1 @@ declare module "*.md"; -declare module "*.woff2" { - const content: string; - export default content; -} - -declare module "*.woff" { - const content: string; - export default content; -} diff --git a/apps/website/screens/guidelines/localization/LocalizationPage.tsx b/apps/website/screens/guidelines/localization/LocalizationPage.tsx index 2fce083188..81aebdd1c4 100644 --- a/apps/website/screens/guidelines/localization/LocalizationPage.tsx +++ b/apps/website/screens/guidelines/localization/LocalizationPage.tsx @@ -5,7 +5,8 @@ import PageHeading from "@/common/PageHeading"; import QuickNavContainer from "@/common/QuickNavContainer"; import { DxcHeading, DxcFlex, DxcTable, DxcParagraph, DxcLink } from "@dxc-technology/halstack-react"; import Link from "next/link"; -import translations from "./examples/translations"; +import translationsExample from "./examples/translations"; +import localizationExample from "./examples/localization"; const sections = [ { @@ -30,7 +31,7 @@ const sections = [ }, { title: "Translation example", - content: , + content: , }, { title: "Default labels", @@ -760,6 +761,21 @@ const sections = [ }, ], }, + { + title: "Localization", + content: ( + <> + + It is also possible to localize some components making use of localeTag to define the language + and region of the component. + + + ), + }, + { + title: "Localization example", + content: , + }, ]; const LocalizationPage = () => ( diff --git a/apps/website/screens/guidelines/localization/examples/localization.tsx b/apps/website/screens/guidelines/localization/examples/localization.tsx new file mode 100644 index 0000000000..d55fbb23d4 --- /dev/null +++ b/apps/website/screens/guidelines/localization/examples/localization.tsx @@ -0,0 +1,24 @@ +import { DxcDateInput, HalstackProvider, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + + return ( + + + + + + ); +}`; + +const scope = { + DxcDateInput, + HalstackProvider, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/utilities/halstack-provider/HalstackProviderPage.tsx b/apps/website/screens/utilities/halstack-provider/HalstackProviderPage.tsx index 28f9825c05..71d82f9d3c 100644 --- a/apps/website/screens/utilities/halstack-provider/HalstackProviderPage.tsx +++ b/apps/website/screens/utilities/halstack-provider/HalstackProviderPage.tsx @@ -1,22 +1,13 @@ import DocFooter from "@/common/DocFooter"; import Example from "@/common/example/Example"; import QuickNavContainer from "@/common/QuickNavContainer"; -import customTranslations from "./examples/customTranslations"; import customThemes from "./examples/customThemes"; -import { - DxcFlex, - DxcParagraph, - DxcTable, - DxcLink, - DxcAlert, - DxcTypography, - DxcBleed, - DxcHeading, -} from "@dxc-technology/halstack-react"; +import { DxcFlex, DxcParagraph, DxcTable, DxcLink, DxcAlert, DxcHeading } from "@dxc-technology/halstack-react"; import Link from "next/link"; import PageHeading from "@/common/PageHeading"; import Code, { ExtendedTableCode, TableCode } from "@/common/Code"; import StatusBadge from "@/common/StatusBadge"; +import customLocalization from "./examples/customLocalization"; const opinionatedThemeTypeString = `{ tokens?: Record; @@ -50,6 +41,17 @@ const sections = [ Object with a given structure, specified below, for defining translations. - + + localeTag + + string + + + String representing the locale, such as "en-US" for English (United States) or "es-ES" for Spanish + (Spain). + + - + @@ -72,7 +74,12 @@ const sections = [ content: ( <> - Halstack Provider can be used to translate all the labels that cannot be changed by the component properties. + Using Halstack Provider localeTag property, we can set the locale for the components. The localeTag property + accepts a string that represents the locale, such as "en-US" for English (United States) or "es-ES" for + Spanish (Spain). By setting this property, some components like the DateInput component will automatically + format the date according to the specified locale. For example, if we set localeTag="de-DE", the DateInput + component will display the date in the German format (DD.MM.YYYY). Halstack Provider can also be used to + translate all the labels that cannot be changed by the component properties. - Let's imagine that we want to translate the '(Optional)' label of a DxcTextInput, as well as the - error messages of our DxcFileInput component. To do so, we need to create an object with the - translations. In this object, you will have as many objects as components you want to translate with the - respective translation for their labels. + Let's imagine that we want to translate the '(Optional)' label of a DxcTextInput. To do so, we + need to create an object with the translations. In this object, you will have as many objects as components + you want to translate with the respective translation for their labels. + + + To change the format in a DxcDateInput component, we can use the localeTag property + of the Halstack Provider or the format prop. The localeTag property accepts a string + that represents the locale, such as "en-US" for English (United States) or "es-ES" for Spanish (Spain). By + setting this property, the DateInput component will automatically format the date according to the specified + locale and also change the first day of the week. For example, if we set localeTag="fr-CH", the{" "} + DxcDateInput component will display the date in the French but using Swiss format (DD.MM.YYYY) + and set Monday as the first day of the week. - - - - *(to see the translated error message you should try to add any file on the DxcFileInput). - - + ), }, diff --git a/apps/website/screens/utilities/halstack-provider/examples/customLocalization.tsx b/apps/website/screens/utilities/halstack-provider/examples/customLocalization.tsx new file mode 100644 index 0000000000..e6700fb36e --- /dev/null +++ b/apps/website/screens/utilities/halstack-provider/examples/customLocalization.tsx @@ -0,0 +1,39 @@ +import { DxcTextInput, HalstackProvider, DxcDateInput, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; + +const code = `() => { + const labels = { + formFields: { + optionalLabel: "(Optionnel)", + }, + }; + + return ( + + + + + + + + + ); +}`; + +const scope = { + DxcTextInput, + DxcDateInput, + HalstackProvider, + DxcInset, + DxcFlex, +}; + +export default { code, scope }; diff --git a/apps/website/screens/utilities/halstack-provider/examples/customTranslations.tsx b/apps/website/screens/utilities/halstack-provider/examples/customTranslations.tsx deleted file mode 100644 index 6829639103..0000000000 --- a/apps/website/screens/utilities/halstack-provider/examples/customTranslations.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { DxcTextInput, DxcFileInput, HalstackProvider, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; -import { useState } from "react"; - -const code = `() => { - const [items, changeItems] = useState(10); - const [files, setFiles] = useState([]); - - const itemsPerPageClick = (value) => { - changeItems(value); - }; - const callbackFile = (files) => { - const updatedFiles = files.map((file) => { - return { ...file }; - }); - setFiles(updatedFiles); - }; - - const labels = { - formFields: { - optionalLabel: "(Optionnel)", - }, - fileInput: { - fileSizeLessThanErrorMessage: - "La taille du fichier doit être inférieure à la taille maximale.", - }, - }; - - return ( - - - - - - - - - ); -}`; - -const scope = { - DxcTextInput, - DxcFileInput, - HalstackProvider, - DxcInset, - DxcFlex, - useState, -}; - -export default { code, scope }; diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index aa9c8211b2..bc92b780d2 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -9,6 +9,6 @@ "@/common/*": ["screens/common/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.ts", "../../packages/lib/src/**/*.d.ts"], "exclude": ["node_modules", "out", ".turbo", ".next"] } diff --git a/packages/lib/src/HalstackContext.tsx b/packages/lib/src/HalstackContext.tsx index cecbe49d4b..5b61dd31b7 100644 --- a/packages/lib/src/HalstackContext.tsx +++ b/packages/lib/src/HalstackContext.tsx @@ -2,7 +2,12 @@ import { createContext, ReactNode, useMemo } from "react"; import styled from "@emotion/styled"; import { css } from "@emotion/react"; import { coreTokens, aliasTokens } from "./styles/tokens"; -import { defaultThemedLogos, TranslatedLabels, defaultTranslatedComponentLabels } from "./common/variables"; +import { + defaultThemedLogos, + TranslatedLabels, + defaultTranslatedComponentLabels, + LocalizedContext, +} from "./common/variables"; /** * This type is used to allow labels objects to be passed to the HalstackProvider. @@ -11,7 +16,7 @@ import { defaultThemedLogos, TranslatedLabels, defaultTranslatedComponentLabels export type DeepPartial = { [P in keyof T]?: Partial; }; -const HalstackLanguageContext = createContext(defaultTranslatedComponentLabels); +const HalstackLanguageContext = createContext({ labels: defaultTranslatedComponentLabels }); const HalstackLogosContext = createContext>(defaultThemedLogos); const parseLabels = (labels: DeepPartial): TranslatedLabels => { @@ -30,12 +35,14 @@ const parseLabels = (labels: DeepPartial): TranslatedLabels => }); return parsedLabels; }; + type ThemeType = { tokens?: Record; logos?: Record }; type HalstackProviderPropsType = { labels?: DeepPartial; children: ReactNode; opinionatedTheme?: ThemeType; + localeTag?: string; }; const HalstackThemed = styled.div<{ coreTheme?: ThemeType["tokens"] }>` @@ -69,7 +76,12 @@ const createCoreTheme = (opinionatedTheme: ThemeType["tokens"] | undefined = {}) return newTheme; }; -const HalstackProvider = ({ labels, children, opinionatedTheme }: HalstackProviderPropsType): JSX.Element => { +const HalstackProvider = ({ + labels, + children, + opinionatedTheme, + localeTag, +}: HalstackProviderPropsType): JSX.Element => { const parsedLabels = useMemo(() => (labels ? parseLabels(labels) : null), [labels]); const parsedCoreTheme = useMemo(() => { const theme = createCoreTheme(opinionatedTheme?.tokens); @@ -79,8 +91,12 @@ const HalstackProvider = ({ labels, children, opinionatedTheme }: HalstackProvid return ( - {parsedLabels ? ( - {children} + {parsedLabels || localeTag ? ( + + {children} + ) : ( children )} diff --git a/packages/lib/src/alert/Alert.tsx b/packages/lib/src/alert/Alert.tsx index 0c78ef5707..050de59d1c 100644 --- a/packages/lib/src/alert/Alert.tsx +++ b/packages/lib/src/alert/Alert.tsx @@ -105,7 +105,7 @@ const DxcAlert = ({ const [currentIndex, setCurrentIndex] = useState(0); const id = useId(); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const handleNextOnClick = () => { setCurrentIndex((prevIndex) => (prevIndex < messages.length ? prevIndex + 1 : prevIndex)); diff --git a/packages/lib/src/card/Card.tsx b/packages/lib/src/card/Card.tsx index 2ce95681b2..8591392ccc 100644 --- a/packages/lib/src/card/Card.tsx +++ b/packages/lib/src/card/Card.tsx @@ -112,7 +112,7 @@ const DxcCard = forwardRef( const [internalSelected, setInternalSelected] = useState( selected !== undefined ? selected : defaultSelected || false ); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; useEffect(() => { if (selected !== undefined) { diff --git a/packages/lib/src/checkbox/Checkbox.tsx b/packages/lib/src/checkbox/Checkbox.tsx index 06badf4663..ddc4f0bb06 100644 --- a/packages/lib/src/checkbox/Checkbox.tsx +++ b/packages/lib/src/checkbox/Checkbox.tsx @@ -93,7 +93,7 @@ const DxcCheckbox = forwardRef( const labelId = `label-checkbox-${useId()}`; const [innerChecked, setInnerChecked] = useState(defaultChecked); const checkboxRef = useRef(null); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const { partial } = useContext(CheckboxContext) ?? {}; const handleOnChange = () => { diff --git a/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts index 5cd2bedddf..f5a58b3784 100644 --- a/packages/lib/src/common/variables.ts +++ b/packages/lib/src/common/variables.ts @@ -140,6 +140,11 @@ export const defaultTranslatedComponentLabels = { export type TranslatedLabels = typeof defaultTranslatedComponentLabels; +export type LocalizedContext = { + labels: TranslatedLabels; + locale?: string; +}; + export const defaultThemedLogos = { mainLogo: undefined, footerLogo: undefined, diff --git a/packages/lib/src/date-input/Calendar.tsx b/packages/lib/src/date-input/Calendar.tsx index 3e2b61b387..7f0a600731 100644 --- a/packages/lib/src/date-input/Calendar.tsx +++ b/packages/lib/src/date-input/Calendar.tsx @@ -3,6 +3,7 @@ import { useContext, useState, useMemo, useEffect, useId, memo, KeyboardEvent, F import styled from "@emotion/styled"; import { CalendarPropsType, DateType } from "./types"; import { HalstackLanguageContext } from "../HalstackContext"; +import { divideDaysIntoWeeks, getCalendarDays, getDateToFocus, isDaySelected, validateLocale } from "./utils"; const CalendarContainer = styled.div` box-sizing: border-box; @@ -91,53 +92,6 @@ const DayCellButton = styled.button<{ } `; -const getDays = (innerDate: Dayjs) => { - const monthDayCells: DateType[] = []; - const lastMonthNumberOfDays = innerDate.set("month", innerDate.get("month") - 1).endOf("month"); - const firstDayOfMonth = innerDate.startOf("month").day() === 0 ? 6 : innerDate.startOf("month").day() - 1; - const daysInMonth = firstDayOfMonth + innerDate.daysInMonth(); - - for (let i = 0; i < 42; i++) { - if (i < firstDayOfMonth) { - monthDayCells.push({ - day: lastMonthNumberOfDays.get("date") - firstDayOfMonth + i + 1, - month: innerDate.get("month") ? innerDate.get("month") - 1 : 11, - year: innerDate.set("month", innerDate.get("month") - 1).get("year"), - }); - } else if (i < daysInMonth) { - monthDayCells.push({ - day: i - firstDayOfMonth + 1, - month: innerDate.get("month"), - year: innerDate.get("year"), - }); - } else { - monthDayCells.push({ - day: i - daysInMonth + 1, - month: innerDate.get("month") === 11 ? 0 : innerDate.get("month") + 1, - year: innerDate.set("month", innerDate.get("month") + 1).get("year"), - }); - } - } - return monthDayCells; -}; - -const getDateToFocus = (selectedDate: Dayjs, innerDate: Dayjs, today: Dayjs) => - selectedDate?.get("month") === innerDate.get("month") && selectedDate?.get("year") === innerDate.get("year") - ? selectedDate - : today.get("month") === innerDate.get("month") && today.get("year") === innerDate.get("year") - ? today - : innerDate.set("date", 1); - -const isDaySelected = (date: DateType, selectedDate: Dayjs) => - selectedDate?.get("month") === date.month && - selectedDate?.get("year") === date.year && - selectedDate?.get("date") === date.day; - -const divideDaysIntoWeeks = (data: DateType[], weekSize: number) => - Array.from({ length: Math.ceil(data.length / weekSize) }, (_, rowIndex) => - data.slice(rowIndex * weekSize, (rowIndex + 1) * weekSize) - ); - const Calendar = ({ selectedDate, innerDate, @@ -148,8 +102,12 @@ const Calendar = ({ const [dateToFocus, setDateToFocus] = useState(getDateToFocus(selectedDate, innerDate, today)); const [isFocusable, setIsFocusable] = useState(false); const id = useId(); - const translatedLabels = useContext(HalstackLanguageContext); - const dayCells = useMemo(() => getDays(innerDate), [innerDate]); + const languageContext = useContext(HalstackLanguageContext); + const translatedLabels = languageContext?.labels; + const localeTag = languageContext?.locale || navigator.language; + const locale = new Intl.Locale(validateLocale(localeTag) ? localeTag : "en"); + const firstDayOfWeek = (locale ? (locale.getWeekInfo?.()?.firstDay ?? 1) : 1) % 7; + const dayCells = useMemo(() => getCalendarDays(innerDate, firstDayOfWeek), [innerDate, firstDayOfWeek]); const onDateClickHandler = (date: DateType) => { const newDate = innerDate.set("month", date.month).set("date", date.day); @@ -184,6 +142,11 @@ const Calendar = ({ } }, [innerDate, dateToFocus, selectedDate, today]); + const orderedWeekDays = useMemo(() => { + const weekDays = translatedLabels.calendar.daysShort; + return [...weekDays.slice(firstDayOfWeek - 1), ...weekDays.slice(0, firstDayOfWeek - 1)]; + }, [translatedLabels.calendar.daysShort]); + const handleDayKeyboardEvent = (event: KeyboardEvent, date: DateType) => { let dateToFocusTemp = date.month === innerDate.get("month") @@ -253,10 +216,12 @@ const Calendar = ({ break; } }; + return ( - {translatedLabels.calendar.daysShort.map((weekDay) => ( + {/* array needs to be changed based on firstDayOfWeek or the array itself */} + {orderedWeekDays.map((weekDay) => ( {weekDay} diff --git a/packages/lib/src/date-input/DateInput.stories.tsx b/packages/lib/src/date-input/DateInput.stories.tsx index c98da33b31..23a5f373b3 100644 --- a/packages/lib/src/date-input/DateInput.stories.tsx +++ b/packages/lib/src/date-input/DateInput.stories.tsx @@ -10,6 +10,7 @@ import DxcDateInput from "./DateInput"; import DxcDatePicker from "./DatePicker"; import YearPicker from "./YearPicker"; import { fireEvent, screen, userEvent, within } from "storybook/internal/test"; +import { HalstackProvider } from "../HalstackContext"; export default { title: "Date Input", @@ -156,6 +157,12 @@ const DatePickerButtonStates = () => ( <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar3" /> </ExampleContainer> + <ExampleContainer> + <Title title="Isolated calendar with locale" theme="light" level={4} /> + <HalstackProvider localeTag="en-US"> + <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar3" /> + </HalstackProvider> + </ExampleContainer> </> ); diff --git a/packages/lib/src/date-input/DateInput.test.tsx b/packages/lib/src/date-input/DateInput.test.tsx index 27655dcfa6..b954489a2a 100644 --- a/packages/lib/src/date-input/DateInput.test.tsx +++ b/packages/lib/src/date-input/DateInput.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import dayjs from "dayjs"; import DxcDateInput from "./DateInput"; import MockDOMRect from "../../test/mocks/domRectMock"; +import { HalstackProvider } from "../HalstackContext"; // Mocking DOMRect for Radix Primitive Popover global.DOMRect = MockDOMRect; @@ -497,6 +498,22 @@ describe("DateInput component tests", () => { userEvent.click(calendarAction); expect(getByText("October 2080")).toBeTruthy(); }); + test("German locale date input", () => { + const { getByRole, getByText } = render( + <HalstackProvider localeTag="de-DE"> + <DxcDateInput label="Date input label" defaultValue="03.12.1995" /> + </HalstackProvider> + ); + const input = getByRole("textbox") as HTMLInputElement; + const calendarAction = getByRole("combobox"); + expect(input.value).toBe("03.12.1995"); + userEvent.click(calendarAction); + const day31 = getByText("31"); + if (day31 != null) { + userEvent.click(day31); + } + expect(input.value).toBe("31.12.1995"); + }); test("Form onSubmit is not called when interacting with the calendar and pressing enter", () => { const onSubmit = jest.fn(); const { getByRole, getAllByText } = render( diff --git a/packages/lib/src/date-input/DateInput.tsx b/packages/lib/src/date-input/DateInput.tsx index e05ee8cec4..6af1c10ea4 100644 --- a/packages/lib/src/date-input/DateInput.tsx +++ b/packages/lib/src/date-input/DateInput.tsx @@ -6,10 +6,9 @@ import { useCallback, useContext, forwardRef, - Dispatch, - SetStateAction, FocusEvent, KeyboardEvent, + useMemo, } from "react"; import dayjs, { Dayjs } from "dayjs"; import styled from "@emotion/styled"; @@ -21,6 +20,7 @@ import DatePicker from "./DatePicker"; import { getMargin } from "../common/utils"; import { spaces } from "../common/variables"; import DxcTextInput from "../text-input/TextInput"; +import { getDate, getFormatFromLocale, getValueForPicker } from "./utils"; dayjs.extend(customParseFormat); @@ -87,32 +87,6 @@ const StyledPopoverContent = styled(Popover.Content)` } `; -const getValueForPicker = (value: string, format: string) => dayjs(value, format.toUpperCase(), true); - -const getDate = ( - value: string, - format: string, - lastValidYear: number | null, - setLastValidYear: Dispatch<SetStateAction<number | null>> -) => { - if ((value || value === "") && format.toUpperCase().includes("YYYY")) { - return getValueForPicker(value, format); - } - let newDate = getValueForPicker(value, format); - if (lastValidYear == null) { - if (+newDate.format("YY") < 68) { - setLastValidYear(2000 + +newDate.format("YY")); - newDate = newDate.set("year", 2000 + +newDate.format("YY")); - } else { - setLastValidYear(1900 + +newDate.format("YY")); - newDate = newDate.set("year", 1900 + +newDate.format("YY")); - } - } else { - newDate = newDate.set("year", (lastValidYear <= 1999 ? 1900 : 2000) + +newDate.format("YY")); - } - return newDate; -}; - const DxcDateInput = forwardRef<RefType, DateInputPropsType>( ( { @@ -120,7 +94,7 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( name, defaultValue = "", value, - format = "dd-MM-yyyy", + format, helperText, placeholder = false, clearable, @@ -141,10 +115,16 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( const [innerValue, setInnerValue] = useState(defaultValue); const [isOpen, setIsOpen] = useState(false); const calendarId = `date-picker-${useId()}`; - const [dayjsDate, setDayjsDate] = useState(getValueForPicker(value ?? defaultValue ?? "", format)); + const languageContext = useContext(HalstackLanguageContext); + const locale = languageContext.locale ? languageContext.locale : undefined; + const formatter = useMemo(() => { + return format ? format : locale ? getFormatFromLocale(locale) : "dd-MM-yyyy"; + }, [format, locale]); + const [dayjsDate, setDayjsDate] = useState(getValueForPicker(value ?? defaultValue ?? "", formatter)); const [lastValidYear, setLastValidYear] = useState<number | null>( innerValue || value - ? !format.toUpperCase().includes("YYYY") && +getValueForPicker(value ?? innerValue, format).format("YY") < 68 + ? !formatter.toUpperCase().includes("YYYY") && + +getValueForPicker(value ?? innerValue, formatter).format("YY") < 68 ? 2000 : 1900 : null @@ -152,12 +132,12 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( const [sideOffset, setSideOffset] = useState(SIDEOFFSET); const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = languageContext.labels; const dateRef = useRef<HTMLDivElement | null>(null); const popoverContentRef = useRef<HTMLDivElement | null>(null); const handleCalendarOnClick = (newDate: Dayjs) => { - const newValue = newDate.format(format.toUpperCase()); + const newValue = newDate.format(formatter.toUpperCase()); if (!value) { setDayjsDate(newDate); setInnerValue(newValue); @@ -179,7 +159,7 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( if (value == null) { setInnerValue(newValue); } - const newDate = getDate(newValue, format, lastValidYear, setLastValidYear); + const newDate = getDate(newValue, formatter, lastValidYear, setLastValidYear); const invalidDateMessage = newValue !== "" && !newDate.isValid() && translatedLabels.dateInput.invalidDateErrorMessage; const callbackParams = { @@ -199,7 +179,7 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( } }; const handleOnBlur = ({ value: blurValue, error: inputError }: { value: string; error?: string }) => { - const date = getDate(blurValue, format, lastValidYear, setLastValidYear); + const date = getDate(blurValue, formatter, lastValidYear, setLastValidYear); const invalidDateMessage = blurValue !== "" && !date.isValid() && translatedLabels.dateInput.invalidDateErrorMessage; const callbackParams = { @@ -273,9 +253,9 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( useEffect(() => { if (value || value === "") { - setDayjsDate(getDate(value, format, lastValidYear, setLastValidYear)); + setDayjsDate(getDate(value, formatter, lastValidYear, setLastValidYear)); } - }, [value, format, lastValidYear]); + }, [value, formatter, lastValidYear]); useEffect(() => { if (!disabled) { @@ -312,7 +292,7 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( name={name} defaultValue={defaultValue} value={value ?? innerValue} - placeholder={placeholder ? format.toUpperCase() : undefined} + placeholder={placeholder ? formatter.toUpperCase() : undefined} action={{ onClick: openCalendar, icon: "filled_calendar_today", diff --git a/packages/lib/src/date-input/DatePicker.tsx b/packages/lib/src/date-input/DatePicker.tsx index 723d8b4bd9..af68c49ff5 100644 --- a/packages/lib/src/date-input/DatePicker.tsx +++ b/packages/lib/src/date-input/DatePicker.tsx @@ -84,7 +84,7 @@ const DatePicker = ({ date, onDateSelect, id }: DatePickerPropsType): JSX.Elemen const [innerDate, setInnerDate] = useState(date?.isValid() ? date : dayjs()); const [content, setContent] = useState("calendar"); const selectedDate = date?.isValid() ? date : dayjs(null); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const handleDateSelect = (chosenDate: Dayjs) => { setInnerDate(chosenDate); diff --git a/packages/lib/src/date-input/utils.tsx b/packages/lib/src/date-input/utils.tsx new file mode 100644 index 0000000000..09d426a90b --- /dev/null +++ b/packages/lib/src/date-input/utils.tsx @@ -0,0 +1,111 @@ +import { Dispatch, SetStateAction } from "react"; +import dayjs, { Dayjs } from "dayjs"; +import { DateType } from "./types"; + +const INVIS_CHARS = /[\u200E\u200F\u061C]/g; + +export const getValueForPicker = (value: string, format: string) => dayjs(value, format.toUpperCase(), true); + +export const validateLocale = (locale: string): boolean => { + let valid = false; + try { + Intl.DateTimeFormat.supportedLocalesOf(locale); + valid = true; + } catch { + valid = false; + } + return valid; +}; + +export const getDate = ( + value: string, + format: string, + lastValidYear: number | null, + setLastValidYear: Dispatch<SetStateAction<number | null>> +) => { + if ((value || value === "") && format.toUpperCase().includes("YYYY")) { + return getValueForPicker(value, format); + } + let newDate = getValueForPicker(value, format); + if (lastValidYear == null) { + if (+newDate.format("YY") < 68) { + setLastValidYear(2000 + +newDate.format("YY")); + newDate = newDate.set("year", 2000 + +newDate.format("YY")); + } else { + setLastValidYear(1900 + +newDate.format("YY")); + newDate = newDate.set("year", 1900 + +newDate.format("YY")); + } + } else { + newDate = newDate.set("year", (lastValidYear <= 1999 ? 1900 : 2000) + +newDate.format("YY")); + } + return newDate; +}; + +export const getFormatFromLocale = (locale: string): string => { + const formatter = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const parts = formatter.formatToParts(new Date(1995, 11, 3)); + + const dateValues: Record<string, string> = { + day: "dd", + month: "MM", + year: "yyyy", + }; + + // if dateValues[part.type] is undefined, it means that the part is a literal (like a separator), so we return the part.value without invisible characters + return parts.map((part) => dateValues[part.type] ?? part.value.replace(INVIS_CHARS, "")).join(""); +}; + +export const getCalendarDays = (innerDate: Dayjs, firstDayOfWeek: number) => { + const monthDayCells: DateType[] = []; + const lastMonthNumberOfDays = innerDate.set("month", innerDate.get("month") - 1).endOf("month"); + const firstDayOfMonth = (innerDate.startOf("month").day() - firstDayOfWeek + 7) % 7; + const daysInMonth = firstDayOfMonth + innerDate.daysInMonth(); + + for (let i = 0; i < 42; i++) { + if (i < firstDayOfMonth) { + // previous month days + monthDayCells.push({ + day: lastMonthNumberOfDays.get("date") - firstDayOfMonth + i + 1, + month: innerDate.get("month") ? innerDate.get("month") - 1 : 11, + year: innerDate.set("month", innerDate.get("month") - 1).get("year"), + }); + } else if (i < daysInMonth) { + // this month days + monthDayCells.push({ + day: i - firstDayOfMonth + 1, + month: innerDate.get("month"), + year: innerDate.get("year"), + }); + } else { + // next month days + monthDayCells.push({ + day: i - daysInMonth + 1, + month: innerDate.get("month") === 11 ? 0 : innerDate.get("month") + 1, + year: innerDate.set("month", innerDate.get("month") + 1).get("year"), + }); + } + } + return monthDayCells; +}; + +export const getDateToFocus = (selectedDate: Dayjs, innerDate: Dayjs, today: Dayjs) => + selectedDate?.get("month") === innerDate.get("month") && selectedDate?.get("year") === innerDate.get("year") + ? selectedDate + : today.get("month") === innerDate.get("month") && today.get("year") === innerDate.get("year") + ? today + : innerDate.set("date", 1); + +export const isDaySelected = (date: DateType, selectedDate: Dayjs) => + selectedDate?.get("month") === date.month && + selectedDate?.get("year") === date.year && + selectedDate?.get("date") === date.day; + +export const divideDaysIntoWeeks = (data: DateType[], weekSize: number) => + Array.from({ length: Math.ceil(data.length / weekSize) }, (_, rowIndex) => + data.slice(rowIndex * weekSize, (rowIndex + 1) * weekSize) + ); diff --git a/packages/lib/src/dialog/Dialog.tsx b/packages/lib/src/dialog/Dialog.tsx index c739f9501c..9380331cee 100644 --- a/packages/lib/src/dialog/Dialog.tsx +++ b/packages/lib/src/dialog/Dialog.tsx @@ -68,7 +68,7 @@ const DxcDialog = ({ disableFocusLock = false, }: DialogPropsType): JSX.Element => { const id = useId(); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null); useEffect(() => { diff --git a/packages/lib/src/file-input/FileInput.tsx b/packages/lib/src/file-input/FileInput.tsx index d60bfc083d..c27d948078 100644 --- a/packages/lib/src/file-input/FileInput.tsx +++ b/packages/lib/src/file-input/FileInput.tsx @@ -133,7 +133,7 @@ const DxcFileInput = forwardRef<RefType, FileInputPropsType>( const [isDragging, setIsDragging] = useState(false); const [files, setFiles] = useState<FileData[]>([]); const fileInputId = `file-input-${useId()}`; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const checkFileSize = (file: File) => { if (minSize && file.size < minSize) { diff --git a/packages/lib/src/file-input/FileItem.tsx b/packages/lib/src/file-input/FileItem.tsx index 5f3a577883..a857d9e43e 100644 --- a/packages/lib/src/file-input/FileItem.tsx +++ b/packages/lib/src/file-input/FileItem.tsx @@ -120,7 +120,7 @@ const FileItem = ({ tabIndex, size = "medium", }: FileItemProps): JSX.Element => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const [hasTooltip, setHasTooltip] = useState(false); const fileNameId = useId(); diff --git a/packages/lib/src/footer/Footer.tsx b/packages/lib/src/footer/Footer.tsx index 41852c6e9c..15c5bfbada 100644 --- a/packages/lib/src/footer/Footer.tsx +++ b/packages/lib/src/footer/Footer.tsx @@ -215,7 +215,7 @@ const DxcFooter = ({ socialLinks, tabIndex = 0, }: FooterPropsType): JSX.Element => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const themedLogos = useContext(HalstackLogosContext); const footerLogo = useMemo(() => { diff --git a/packages/lib/src/intl.d.ts b/packages/lib/src/intl.d.ts new file mode 100644 index 0000000000..226338ac94 --- /dev/null +++ b/packages/lib/src/intl.d.ts @@ -0,0 +1,14 @@ +export {}; +declare global { + namespace Intl { + interface WeekInfo { + firstDay: number; + weekend: number[]; + minimalDays: number; + } + + interface Locale { + getWeekInfo(): WeekInfo; + } + } +} diff --git a/packages/lib/src/paginator/Paginator.tsx b/packages/lib/src/paginator/Paginator.tsx index c691a766c9..fb536ae5a3 100644 --- a/packages/lib/src/paginator/Paginator.tsx +++ b/packages/lib/src/paginator/Paginator.tsx @@ -80,7 +80,7 @@ const DxcPaginator = ({ const maxItemsPerPage = minItemsPerPage - 1 + itemsPerPage > totalItems ? totalItems : minItemsPerPage - 1 + itemsPerPage; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const containerRef = useRef<HTMLDivElement | null>(null); const width = useWidth(containerRef); diff --git a/packages/lib/src/password-input/PasswordInput.tsx b/packages/lib/src/password-input/PasswordInput.tsx index cd3a040fea..4f1388023b 100644 --- a/packages/lib/src/password-input/PasswordInput.tsx +++ b/packages/lib/src/password-input/PasswordInput.tsx @@ -44,7 +44,7 @@ const DxcPasswordInput = forwardRef<RefType, PasswordInputPropsType>( ) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false); const inputRef = useRef<HTMLDivElement | null>(null); - const { passwordInput } = useContext(HalstackLanguageContext); + const { passwordInput } = useContext(HalstackLanguageContext).labels; useEffect(() => { (() => { diff --git a/packages/lib/src/quick-nav/QuickNav.tsx b/packages/lib/src/quick-nav/QuickNav.tsx index 4131314a82..4f482da4a2 100644 --- a/packages/lib/src/quick-nav/QuickNav.tsx +++ b/packages/lib/src/quick-nav/QuickNav.tsx @@ -60,7 +60,7 @@ const Link = styled.a` `; export default function DxcQuickNav({ links, title }: QuickNavTypes) { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const isHashRouter = (): boolean => { if (typeof window === "undefined") return false; return window.location.href.includes("/#/"); diff --git a/packages/lib/src/radio-group/RadioGroup.tsx b/packages/lib/src/radio-group/RadioGroup.tsx index 357a276ca6..2768304537 100644 --- a/packages/lib/src/radio-group/RadioGroup.tsx +++ b/packages/lib/src/radio-group/RadioGroup.tsx @@ -46,7 +46,7 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( const id = `radio-group-${useId()}`; const labelId = `label-${id}`; const errorId = `error-${id}`; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const innerOptions = useMemo( () => optional diff --git a/packages/lib/src/search-bar/SearchBar.tsx b/packages/lib/src/search-bar/SearchBar.tsx index 42440567c4..dd7bfe5736 100644 --- a/packages/lib/src/search-bar/SearchBar.tsx +++ b/packages/lib/src/search-bar/SearchBar.tsx @@ -68,7 +68,7 @@ const DxcSearchBar = ({ onEnter, placeholder = "Search...", }: SearchBarProps) => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const inputRef = useRef<HTMLInputElement>(null); const [innerValue, setInnerValue] = useState(""); diff --git a/packages/lib/src/search-bar/SearchBarTrigger.tsx b/packages/lib/src/search-bar/SearchBarTrigger.tsx index e0b49d0028..f1e5c1bace 100644 --- a/packages/lib/src/search-bar/SearchBarTrigger.tsx +++ b/packages/lib/src/search-bar/SearchBarTrigger.tsx @@ -4,7 +4,7 @@ import { HalstackLanguageContext } from "../HalstackContext"; import { SearchBarTriggerProps } from "./types"; const DxcSearchBarTrigger = ({ onTriggerClick }: SearchBarTriggerProps) => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; return ( <DxcButton onClick={onTriggerClick} diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index 9a20c521d7..86fa7608a5 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -69,7 +69,7 @@ const VirtualizedListbox = ({ virtualizedHeight, visualFocusIndex, }: ListboxProps) => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const virtuosoRef = useRef<VirtuosoHandle>(null); const isSearchEmpty = searchable && (options.length === 0 || !groupsHaveOptions(options)); @@ -288,7 +288,7 @@ const NonVirtualizedListbox = ({ styles, visualFocusIndex, }: ListboxProps) => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const listboxRef = useRef<HTMLDivElement>(null); let globalMappingIndex = (multiple ? enableSelectAll : optional) ? 0 : -1; diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index 07b982e4db..0544cfb84e 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -220,7 +220,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( const selectSearchInputRef = useRef<HTMLInputElement | null>(null); const width = useWidth(selectRef); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const optionalItem = useMemo(() => ({ label: placeholder, value: "" }), [placeholder]); const filteredOptions = useMemo( diff --git a/packages/lib/src/switch/Switch.tsx b/packages/lib/src/switch/Switch.tsx index 704db66dcc..9c7a41fd32 100644 --- a/packages/lib/src/switch/Switch.tsx +++ b/packages/lib/src/switch/Switch.tsx @@ -130,7 +130,7 @@ const DxcSwitch = forwardRef<RefType, SwitchPropsType>( ref ) => { const [innerChecked, setInnerChecked] = useState(defaultChecked); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const handleOnChange = () => { if (checked == null) setInnerChecked((currentInnerChecked) => !currentInnerChecked); diff --git a/packages/lib/src/tabs/Tabs.tsx b/packages/lib/src/tabs/Tabs.tsx index 4d96e056ba..213c99da5e 100644 --- a/packages/lib/src/tabs/Tabs.tsx +++ b/packages/lib/src/tabs/Tabs.tsx @@ -104,7 +104,7 @@ const DxcTabs = ({ children, iconPosition = "left", margin, tabIndex = 0 }: Tabs const [totalTabsWidth, setTotalTabsWidth] = useState(0); const refTabListContainer = useRef<HTMLDivElement | null>(null); const refTabList = useRef<HTMLDivElement | null>(null); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const viewWidth = useWidth(refTabList); const contextValue = useMemo(() => { const focusedChild = innerFocusIndex != null ? childrenArray[innerFocusIndex] : null; diff --git a/packages/lib/src/text-input/Suggestions.tsx b/packages/lib/src/text-input/Suggestions.tsx index 7757d106b0..7355ada6f3 100644 --- a/packages/lib/src/text-input/Suggestions.tsx +++ b/packages/lib/src/text-input/Suggestions.tsx @@ -54,7 +54,7 @@ const Suggestions = ({ value, visualFocusIndex, }: SuggestionsProps) => { - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const listboxRef = useRef<HTMLUListElement | null>(null); useEffect(() => { diff --git a/packages/lib/src/text-input/TextInput.tsx b/packages/lib/src/text-input/TextInput.tsx index ab40af511e..2b29115741 100644 --- a/packages/lib/src/text-input/TextInput.tsx +++ b/packages/lib/src/text-input/TextInput.tsx @@ -143,7 +143,7 @@ const DxcTextInput = forwardRef<RefType, TextInputPropsType>( const inputId = `input-${useId()}`; const autosuggestId = `suggestions-${inputId}`; const errorId = `error-${inputId}`; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const numberInputContext = useContext(NumberInputContext); const inputRef = useRef<HTMLInputElement | null>(null); const inputContainerRef = useRef<HTMLDivElement | null>(null); diff --git a/packages/lib/src/textarea/Textarea.tsx b/packages/lib/src/textarea/Textarea.tsx index c2013092fc..b180dcb387 100644 --- a/packages/lib/src/textarea/Textarea.tsx +++ b/packages/lib/src/textarea/Textarea.tsx @@ -95,7 +95,7 @@ const DxcTextarea = forwardRef<RefType, TextareaPropsType>( const [innerValue, setInnerValue] = useState(defaultValue); const textareaId = `textarea-${useId()}`; const errorId = `error-${textareaId}`; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; const textareaRef = useRef<HTMLTextAreaElement | null>(null); const prevValueRef = useRef<string | null>(null); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index d18a4310fa..cb0eb0c235 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -90,7 +90,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const secondRef = useRef<HTMLSpanElement>(null); const dayPeriodRef = useRef<HTMLSpanElement>(null); const isControlled = value !== undefined; - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; useEffect(() => { const time = value || defaultValue || undefined; diff --git a/packages/lib/src/toast/Toast.tsx b/packages/lib/src/toast/Toast.tsx index 8f73a1ef15..ca7eccca31 100644 --- a/packages/lib/src/toast/Toast.tsx +++ b/packages/lib/src/toast/Toast.tsx @@ -93,7 +93,7 @@ const DxcToast = ({ const [isClosing, setIsClosing] = useState(false); const toastRef = useRef<HTMLOutputElement>(null); const previouslyFocusedElement = useRef<HTMLElement | null>(null); - const translatedLabels = useContext(HalstackLanguageContext); + const translatedLabels = useContext(HalstackLanguageContext).labels; // Timeouts const clearClosingAnimationTimer = useTimeout(() => setIsClosing(true), loading ? undefined : duration - 300); diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 5330ab9f7f..e0794eabca 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -3,8 +3,9 @@ "compilerOptions": { "jsx": "react-jsx", "outDir": "dist", - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext"] }, - "include": ["src", "test", ".", ".storybook/**/*", "src/global.d.ts"], + "include": ["src", "test", ".", ".storybook/**/*", "src/**/*.d.ts"], "exclude": ["node_modules", "dist", "coverage", ".turbo"] }