From 65c7db769407c36175abb123db2a3ac278079a95 Mon Sep 17 00:00:00 2001 From: jialecl Date: Mon, 13 Jul 2026 14:46:29 +0200 Subject: [PATCH 1/7] Time input localization support --- .../lib/src/time-input/TimeInput.stories.tsx | 18 ++ .../lib/src/time-input/TimeInput.test.tsx | 69 +++++++ packages/lib/src/time-input/TimeInput.tsx | 180 +++++++++++++++--- packages/lib/src/time-input/utils.ts | 53 +++++- 4 files changed, 289 insertions(+), 31 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 9a8632bda..16fbab982 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -7,6 +7,7 @@ import preview from "../../.storybook/preview"; import disabledRules from "../../test/accessibility/rules/common/disabledRules"; import DxcContainer from "../container/Container"; import { userEvent, within } from "storybook/internal/test"; +import { HalstackProvider } from "../HalstackContext"; export default { title: "Time Input", @@ -100,6 +101,23 @@ const TimeInput = () => { <TimeInputExamples /> </ExampleContainer> + <ExampleContainer> + <Title title="Localized time input" theme="light" level={3} /> + <HalstackProvider localeTag="fi-FI"> + <DxcTimeInput label="Finnish locale" helperText="Helper text" defaultValue={continentalValue} showSeconds /> + </HalstackProvider> + <HalstackProvider localeTag="en-US"> + <DxcTimeInput label="US locale" helperText="Helper text" defaultValue={value} showSeconds /> + </HalstackProvider> + <HalstackProvider localeTag="zh-TW"> + <DxcTimeInput + label="Taiwanese locale" + helperText="Helper text" + onChange={(val) => console.log(val)} + showSeconds + /> + </HalstackProvider> + </ExampleContainer> </> ); }; diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index b1c9451ca..b9a1b01d0 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -3,6 +3,7 @@ import DxcTimeInput from "./TimeInput"; import MockDOMRect from "../../test/mocks/domRectMock"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; +import { HalstackProvider } from "../HalstackContext"; // Mocking DOMRect for Radix Primitive Popover global.DOMRect = MockDOMRect; @@ -265,4 +266,72 @@ describe("DxcTimeInput rendering", () => { userEvent.keyboard("{5}"); expect(mockOnChange).toHaveBeenCalledWith("12:05:00 AM"); }); + + it("Time input with finnish locale", () => { + const mockOnChange = jest.fn(); + const { getByRole, getAllByText } = render( + <HalstackProvider localeTag="fi-FI"> + <DxcTimeInput label="Finnish locale" showSeconds onChange={mockOnChange} /> + </HalstackProvider> + ); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + const hourbutton = getAllByText("16"); + if (hourbutton[0]) userEvent.click(hourbutton[0]); + expect(mockOnChange).toHaveBeenCalledWith("16.00.00"); + const minuteButton = getAllByText("30"); + if (minuteButton[0]) userEvent.click(minuteButton[0]); + expect(mockOnChange).toHaveBeenCalledWith("16.30.00"); + const secondButton = getAllByText("45"); + if (secondButton[1]) userEvent.click(secondButton[1]); + expect(mockOnChange).toHaveBeenCalledWith("16.30.45"); + }); + + it("Time input with taiwanese locale", () => { + const mockOnChange = jest.fn(); + const { getByRole, getAllByText } = render( + <HalstackProvider localeTag="zh-TW"> + <DxcTimeInput label="Taiwanese locale" showSeconds onChange={mockOnChange} /> + </HalstackProvider> + ); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + const amButton = getAllByText("AM")[0]; + if (amButton) userEvent.click(amButton); + const hourbutton = getAllByText("07"); + if (hourbutton[0]) userEvent.click(hourbutton[0]); + expect(mockOnChange).toHaveBeenCalledWith("AM 07:00:00"); + const minuteButton = getAllByText("30"); + if (minuteButton[0]) userEvent.click(minuteButton[0]); + expect(mockOnChange).toHaveBeenCalledWith("AM 07:30:00"); + const secondButton = getAllByText("45"); + if (secondButton[1]) userEvent.click(secondButton[1]); + expect(mockOnChange).toHaveBeenCalledWith("AM 07:30:45"); + }); + + it("Time input with taiwanese locale, but using keyboard to input time", () => { + const mockOnChange = jest.fn(); + const { getAllByRole } = render( + <HalstackProvider localeTag="zh-TW"> + <DxcTimeInput label="Taiwanese locale" showSeconds onChange={mockOnChange} /> + </HalstackProvider> + ); + const inputs = getAllByRole("spinbutton"); + expect(inputs).toHaveLength(4); + userEvent.tab(); + expect(inputs[0]).toHaveFocus(); + userEvent.keyboard("p"); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("1"); + userEvent.keyboard("2"); + expect(inputs[2]).toHaveFocus(); + userEvent.keyboard("{3}"); + userEvent.keyboard("{4}"); + expect(inputs[3]).toHaveFocus(); + userEvent.keyboard("{5}"); + userEvent.keyboard("{6}"); + expect(mockOnChange).toHaveBeenCalledWith("PM 12:34:56"); + }); }); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index cb0eb0c23..d8b7a09dc 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -1,7 +1,7 @@ import styled from "@emotion/styled"; import inputStylesByState from "../styles/forms/inputStylesByState"; import TimeInputPropsType, { RefType } from "./types"; -import { forwardRef, useContext, useEffect, useId, useRef, useState } from "react"; +import { forwardRef, useContext, useEffect, useId, useMemo, useRef, useState } from "react"; import { HalstackLanguageContext } from "../HalstackContext"; import Label from "../styles/forms/Label"; import HelperText from "../styles/forms/HelperText"; @@ -10,7 +10,7 @@ import DxcFlex from "../flex/Flex"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcPopover from "../popover/Popover"; import TimePicker from "./TimePicker"; -import { generateEventValue } from "./utils"; +import { generateEventValue, getTimeInputLocale } from "./utils"; import ErrorMessage from "../styles/forms/ErrorMessage"; const sizes = { @@ -73,7 +73,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds = false, size = "medium", tabIndex = 0, - timeFormat = "12", + timeFormat, value, }, ref @@ -90,19 +90,25 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const secondRef = useRef<HTMLSpanElement>(null); const dayPeriodRef = useRef<HTMLSpanElement>(null); const isControlled = value !== undefined; - const translatedLabels = useContext(HalstackLanguageContext).labels; - + const languageContext = useContext(HalstackLanguageContext); + const translatedLabels = languageContext.labels; + const formatInfo = useMemo(() => getTimeInputLocale(languageContext.locale), [languageContext.locale]); + // Prop timeFormat takes precedence over the locale format + const timeFormatToUse = useMemo<"12" | "24">( + () => timeFormat || formatInfo.format, + [timeFormat, formatInfo.format] + ); useEffect(() => { const time = value || defaultValue || undefined; if (time) { - const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; + const numberPart = timeFormatToUse === "12" ? time.split(" ")[0] : time; if (numberPart) { const [hourStr, minuteStr, secondStr] = numberPart.split(":"); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); setMinuteValue(minuteStr && isNumber(minuteStr) ? Number(minuteStr) : undefined); setSecondValue(secondStr && isNumber(secondStr) ? Number(secondStr) : undefined); } - if (timeFormat === "12" && time.includes(" ")) { + if (timeFormatToUse === "12" && time.includes(" ")) { const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; setDayPeriodValue(dayPeriodValue); } else { @@ -114,13 +120,22 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setSecondValue(undefined); setDayPeriodValue(undefined); } - }, [value, defaultValue, timeFormat]); + }, [value, defaultValue, timeFormatToUse]); const generatedInputValue = () => { if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { return ""; } else { - return generateEventValue(hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat); + return generateEventValue( + hourValue, + minuteValue, + secondValue, + dayPeriodValue, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ); } }; @@ -132,13 +147,24 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setDayPeriodValue(undefined); } if (typeof onChange === "function") { - onChange(generateEventValue(undefined, undefined, undefined, undefined, showSeconds, timeFormat)); + onChange( + generateEventValue( + undefined, + undefined, + undefined, + undefined, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) + ); } }; const validateTimeValue = (value: string) => { const timeRegex = - timeFormat === "12" + timeFormatToUse === "12" ? /^(0?[1-9]|1[0-2]):[0-5][0-9](?::[0-5][0-9])?\s?(AM|PM)$/i : /^([01]?[0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/; if (!timeRegex.test(value)) { @@ -149,7 +175,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( (hourValue === undefined || minuteValue === undefined || (showSeconds && secondValue === undefined) || - (timeFormat === "12" && dayPeriodValue === undefined)) + (timeFormatToUse === "12" && dayPeriodValue === undefined)) ) { return "This field is required"; } @@ -184,12 +210,55 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( )} <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center" fullHeight> + {timeFormatToUse === "12" && formatInfo.dayPeriodPosition === "before" && ( + <TimeSpinButton + ariaLabel={label ?? ariaLabel} + value={dayPeriodValue} + minValue={0} + maxValue={1} + tabIndex={tabIndex} + dataType="dayPeriod" + readOnly={readOnly} + disabled={disabled} + isControlled={isControlled} + onChange={(value) => { + if (!isControlled) { + setDayPeriodValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue( + hourValue, + minuteValue, + secondValue, + value, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) + ); + } + }} + onComplete={() => { + if (hourRef.current) { + hourRef.current.focus(); + } + }} + onNext={() => { + if (hourRef.current) { + hourRef.current.focus(); + } + }} + ref={dayPeriodRef} + /> + )} <DxcFlex alignItems="center" fullHeight> <TimeSpinButton ariaLabel={label ?? ariaLabel} value={hourValue} - minValue={timeFormat === "12" ? 1 : 0} - maxValue={timeFormat === "12" ? 12 : 23} + minValue={timeFormatToUse === "12" ? 1 : 0} + maxValue={timeFormatToUse === "12" ? 12 : 23} tabIndex={tabIndex} dataType="hour" readOnly={readOnly} @@ -206,7 +275,16 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } if (typeof onChange === "function") { onChange( - generateEventValue(value, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat) + generateEventValue( + value, + minuteValue, + secondValue, + dayPeriodValue, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) ); } }} @@ -217,7 +295,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( }} ref={hourRef} /> - <ColonContainer>:</ColonContainer> + <ColonContainer>{formatInfo.separator}</ColonContainer> <TimeSpinButton ariaLabel={label ?? ariaLabel} value={minuteValue} @@ -231,7 +309,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onComplete={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); - } else if (timeFormat === "12" && dayPeriodRef.current) { + } else if (timeFormatToUse === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); } }} @@ -241,14 +319,23 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } if (typeof onChange === "function") { onChange( - generateEventValue(hourValue, value, secondValue, dayPeriodValue, showSeconds, timeFormat) + generateEventValue( + hourValue, + value, + secondValue, + dayPeriodValue, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) ); } }} onNext={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); - } else if (timeFormat === "12" && dayPeriodRef.current) { + } else if (timeFormatToUse === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); } }} @@ -261,7 +348,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( /> {showSeconds && ( <> - <ColonContainer>:</ColonContainer> + <ColonContainer>{formatInfo.separator}</ColonContainer> <TimeSpinButton ariaLabel={label ?? ariaLabel} value={secondValue} @@ -273,7 +360,11 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( disabled={disabled} isControlled={isControlled} onComplete={() => { - if (timeFormat === "12" && dayPeriodRef.current) { + if ( + timeFormatToUse === "12" && + formatInfo.dayPeriodPosition === "after" && + dayPeriodRef.current + ) { dayPeriodRef.current.focus(); } }} @@ -283,12 +374,25 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } if (typeof onChange === "function") { onChange( - generateEventValue(hourValue, minuteValue, value, dayPeriodValue, showSeconds, timeFormat) + generateEventValue( + hourValue, + minuteValue, + value, + dayPeriodValue, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) ); } }} onNext={() => { - if (timeFormat === "12" && dayPeriodRef.current) { + if ( + timeFormatToUse === "12" && + formatInfo.dayPeriodPosition === "after" && + dayPeriodRef.current + ) { dayPeriodRef.current.focus(); } }} @@ -302,7 +406,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </> )} </DxcFlex> - {timeFormat === "12" && ( + {timeFormatToUse === "12" && formatInfo.dayPeriodPosition === "after" && ( <TimeSpinButton ariaLabel={label ?? ariaLabel} value={dayPeriodValue} @@ -318,7 +422,18 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setDayPeriodValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue(hourValue, minuteValue, secondValue, value, showSeconds, timeFormat)); + onChange( + generateEventValue( + hourValue, + minuteValue, + secondValue, + value, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) + ); } }} onPrevious={() => { @@ -353,10 +468,21 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setHourValue(hour); } if (typeof onChange === "function") { - onChange(generateEventValue(hour, minute, second, dayPeriod, showSeconds, timeFormat)); + onChange( + generateEventValue( + hour, + minute, + second, + dayPeriod, + showSeconds, + timeFormatToUse, + formatInfo.separator, + formatInfo.dayPeriodPosition + ) + ); } }} - timeFormat={timeFormat} + timeFormat={timeFormatToUse} showSeconds={showSeconds} hourValue={hourValue} minuteValue={minuteValue} diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index def5a10bf..cf078f771 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -95,6 +95,9 @@ export const handleKeyDown = ( const isAM = /[aA]/.test(event.key); newValue = isAM ? 0 : 1; rawInput.current = newValue.toString(); + if (typeof onComplete === "function") { + onComplete(); + } } setInnerValue((prevValue) => { return prevValue !== newValue ? newValue : prevValue; @@ -120,14 +123,21 @@ export const generateEventValue = ( second: number | undefined, dayPeriod: number | undefined, showSeconds: boolean | undefined, - timeFormat: "12" | "24" | undefined + timeFormat: "12" | "24" | undefined, + separator: string, + timePeriodPosition: "before" | "after" ) => { if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { return ""; } - return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ - timeFormat === "12" ? ` ${returnDayPeriod(dayPeriod)}` : "" - }`; + // consider dayperiod position for 12-hour format otherwise ignore it + if (timeFormat === "12" && timePeriodPosition === "before") { + return `${returnDayPeriod(dayPeriod)} ${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""}`; + } else if (timeFormat === "12" && timePeriodPosition === "after") { + return `${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""} ${returnDayPeriod(dayPeriod)}`; + } else { + return `${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""}`; + } }; export const handleColumnKeyDown = ( @@ -172,3 +182,38 @@ export const handleColumnKeyDown = ( } } }; + +export const getTimeInputLocale = ( + locale?: string +): { separator: string; format: "12" | "24"; dayPeriodPosition: "before" | "after" } => { + if (!locale) { + return { + separator: ":", + format: "12", + dayPeriodPosition: "after", + }; + } + const naturalCycle = new Intl.DateTimeFormat(locale, { hour: "2-digit" }).resolvedOptions().hourCycle; + const hourCycle: "h12" | "h23" = naturalCycle === "h23" || naturalCycle === "h24" ? "h23" : "h12"; + + const formatter = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle, + }); + const parts = formatter.formatToParts(new Date(1995, 11, 3, 1, 0, 0)); + const dayPeriodPosition = parts[0]?.type === "dayPeriod" ? "before" : "after"; + const format = hourCycle === "h23" ? "24" : "12"; + // get all parts that are "literal" and use the first one or the last one depending on the dayPeriodPosition to determine the separator + const separatorParts = parts.filter((part) => part.type === "literal"); + const separator = + format === "24" || dayPeriodPosition === "after" + ? separatorParts[0]?.value || ":" + : separatorParts[separatorParts.length - 1]?.value || ":"; + return { + separator, + format, + dayPeriodPosition, + }; +}; From 83b5ad57080bd33ddd4f884a6345ae87d5fa900c Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Tue, 14 Jul 2026 13:21:37 +0200 Subject: [PATCH 2/7] Added better support based on format and locale --- .../lib/src/time-input/TimeInput.stories.tsx | 20 +++++++ .../lib/src/time-input/TimeInput.test.tsx | 23 ++++++++ packages/lib/src/time-input/TimeInput.tsx | 52 +++++++++---------- packages/lib/src/time-input/TimePicker.tsx | 21 +++++++- packages/lib/src/time-input/types.ts | 1 + packages/lib/src/time-input/utils.ts | 12 +++-- 6 files changed, 97 insertions(+), 32 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 16fbab982..0b0eefdee 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -117,6 +117,15 @@ const TimeInput = () => { showSeconds /> </HalstackProvider> + <HalstackProvider localeTag="zh-CN"> + <DxcTimeInput + label="Chinese locale with 12-hour format" + helperText="Helper text" + onChange={(val) => console.log(val)} + timeFormat="12" + showSeconds + /> + </HalstackProvider> </ExampleContainer> </> ); @@ -255,6 +264,17 @@ const TimePickerExamples = () => { /> </DxcContainer> </ExampleContainer> + <ExampleContainer> + <Title title="Localized time picker" theme="light" level={3} /> + <TimePicker + onPickTime={() => {}} + timeFormat="12" + dayPeriodPosition="before" + id="testId" + tabIndex={0} + showSeconds + /> + </ExampleContainer> </> ); }; diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index b9a1b01d0..b260f2d16 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -334,4 +334,27 @@ describe("DxcTimeInput rendering", () => { userEvent.keyboard("{6}"); expect(mockOnChange).toHaveBeenCalledWith("PM 12:34:56"); }); + + it("Time input with chinese locale, but using time picker", () => { + const mockOnChange = jest.fn(); + const { getByRole, getAllByText } = render( + <HalstackProvider localeTag="zh-CN"> + <DxcTimeInput label="Chinese locale" timeFormat="12" showSeconds onChange={mockOnChange} /> + </HalstackProvider> + ); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + const amButton = getAllByText("AM")[0]; + if (amButton) userEvent.click(amButton); + const hourbutton = getAllByText("12"); + if (hourbutton[0]) userEvent.click(hourbutton[0]); + expect(mockOnChange).toHaveBeenCalledWith("AM 12:00:00"); + const minuteButton = getAllByText("30"); + if (minuteButton[0]) userEvent.click(minuteButton[0]); + expect(mockOnChange).toHaveBeenCalledWith("AM 12:30:00"); + const secondButton = getAllByText("50"); + if (secondButton[1]) userEvent.click(secondButton[1]); + expect(mockOnChange).toHaveBeenCalledWith("AM 12:30:50"); + }); }); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index d8b7a09dc..57cb7713b 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -92,23 +92,18 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const isControlled = value !== undefined; const languageContext = useContext(HalstackLanguageContext); const translatedLabels = languageContext.labels; - const formatInfo = useMemo(() => getTimeInputLocale(languageContext.locale), [languageContext.locale]); - // Prop timeFormat takes precedence over the locale format - const timeFormatToUse = useMemo<"12" | "24">( - () => timeFormat || formatInfo.format, - [timeFormat, formatInfo.format] - ); + const formatInfo = useMemo(() => getTimeInputLocale(languageContext.locale, timeFormat), [languageContext.locale]); useEffect(() => { const time = value || defaultValue || undefined; if (time) { - const numberPart = timeFormatToUse === "12" ? time.split(" ")[0] : time; + const numberPart = formatInfo.format === "12" ? time.split(" ")[0] : time; if (numberPart) { const [hourStr, minuteStr, secondStr] = numberPart.split(":"); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); setMinuteValue(minuteStr && isNumber(minuteStr) ? Number(minuteStr) : undefined); setSecondValue(secondStr && isNumber(secondStr) ? Number(secondStr) : undefined); } - if (timeFormatToUse === "12" && time.includes(" ")) { + if (formatInfo.format === "12" && time.includes(" ")) { const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; setDayPeriodValue(dayPeriodValue); } else { @@ -120,7 +115,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setSecondValue(undefined); setDayPeriodValue(undefined); } - }, [value, defaultValue, timeFormatToUse]); + }, [value, defaultValue, formatInfo.format]); const generatedInputValue = () => { if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { @@ -132,7 +127,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( secondValue, dayPeriodValue, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ); @@ -154,7 +149,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( undefined, undefined, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -164,7 +159,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const validateTimeValue = (value: string) => { const timeRegex = - timeFormatToUse === "12" + formatInfo.format === "12" ? /^(0?[1-9]|1[0-2]):[0-5][0-9](?::[0-5][0-9])?\s?(AM|PM)$/i : /^([01]?[0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/; if (!timeRegex.test(value)) { @@ -175,7 +170,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( (hourValue === undefined || minuteValue === undefined || (showSeconds && secondValue === undefined) || - (timeFormatToUse === "12" && dayPeriodValue === undefined)) + (formatInfo.format === "12" && dayPeriodValue === undefined)) ) { return "This field is required"; } @@ -210,7 +205,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( )} <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center" fullHeight> - {timeFormatToUse === "12" && formatInfo.dayPeriodPosition === "before" && ( + {formatInfo.format === "12" && formatInfo.dayPeriodPosition === "before" && ( <TimeSpinButton ariaLabel={label ?? ariaLabel} value={dayPeriodValue} @@ -233,7 +228,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( secondValue, value, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -257,8 +252,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( <TimeSpinButton ariaLabel={label ?? ariaLabel} value={hourValue} - minValue={timeFormatToUse === "12" ? 1 : 0} - maxValue={timeFormatToUse === "12" ? 12 : 23} + minValue={formatInfo.format === "12" ? 1 : 0} + maxValue={formatInfo.format === "12" ? 12 : 23} tabIndex={tabIndex} dataType="hour" readOnly={readOnly} @@ -281,7 +276,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( secondValue, dayPeriodValue, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -309,7 +304,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onComplete={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); - } else if (timeFormatToUse === "12" && dayPeriodRef.current) { + } else if (formatInfo.format === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); } }} @@ -325,7 +320,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( secondValue, dayPeriodValue, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -335,7 +330,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onNext={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); - } else if (timeFormatToUse === "12" && dayPeriodRef.current) { + } else if (formatInfo.format === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); } }} @@ -361,7 +356,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( isControlled={isControlled} onComplete={() => { if ( - timeFormatToUse === "12" && + formatInfo.format === "12" && formatInfo.dayPeriodPosition === "after" && dayPeriodRef.current ) { @@ -380,7 +375,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( value, dayPeriodValue, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -389,7 +384,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( }} onNext={() => { if ( - timeFormatToUse === "12" && + formatInfo.format === "12" && formatInfo.dayPeriodPosition === "after" && dayPeriodRef.current ) { @@ -406,7 +401,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </> )} </DxcFlex> - {timeFormatToUse === "12" && formatInfo.dayPeriodPosition === "after" && ( + {formatInfo.format === "12" && formatInfo.dayPeriodPosition === "after" && ( <TimeSpinButton ariaLabel={label ?? ariaLabel} value={dayPeriodValue} @@ -429,7 +424,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( secondValue, value, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) @@ -475,14 +470,15 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( second, dayPeriod, showSeconds, - timeFormatToUse, + formatInfo.format, formatInfo.separator, formatInfo.dayPeriodPosition ) ); } }} - timeFormat={timeFormatToUse} + timeFormat={formatInfo.format} + dayPeriodPosition={formatInfo.dayPeriodPosition} showSeconds={showSeconds} hourValue={hourValue} minuteValue={minuteValue} diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 5d43d71b8..36ce6fd99 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -16,6 +16,7 @@ const TimePickerContainer = styled.div` const TimePicker = ({ onPickTime, timeFormat, + dayPeriodPosition, showSeconds, hourValue, minuteValue, @@ -66,6 +67,24 @@ const TimePicker = ({ return ( <TimePickerContainer role="listbox" aria-label="Time picker"> + {timeFormat === "12" && dayPeriodPosition === "before" && ( + <TimePickerColumn + valuesArray={[0, 1]} + id={id} + selectedValue={dayPeriod} + valueToFocus={dayPeriodToFocus} + tabIndex={tabIndex} + dataType="dayPeriod" + onClick={(value: number) => { + onPickerSelect(value, "dayPeriod"); + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "dayPeriod", value, 2, setDayPeriodToFocus, (value) => + onPickerSelect(value, "dayPeriod") + ) + } + /> + )} <TimePickerColumn valuesArray={Array.from({ length: totalHours }, (_, index) => index)} id={id} @@ -128,7 +147,7 @@ const TimePicker = ({ } /> )} - {timeFormat === "12" && ( + {timeFormat === "12" && dayPeriodPosition !== "before" && ( <TimePickerColumn valuesArray={[0, 1]} id={id} diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index e10e787af..ca00d4bd5 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -106,6 +106,7 @@ export type TimeSpinButtonPropsType = { export type TimePickerPropsType = { onPickTime: (hours: number, minutes: number, seconds?: number, dayPeriod?: number) => void; timeFormat: "12" | "24"; + dayPeriodPosition?: "before" | "after"; showSeconds?: boolean; hourValue?: number; minuteValue?: number; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index cf078f771..5ae44f08c 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -184,16 +184,21 @@ export const handleColumnKeyDown = ( }; export const getTimeInputLocale = ( - locale?: string + locale?: string, + formatProp?: "12" | "24" ): { separator: string; format: "12" | "24"; dayPeriodPosition: "before" | "after" } => { if (!locale) { return { separator: ":", - format: "12", + format: formatProp || "12", dayPeriodPosition: "after", }; } - const naturalCycle = new Intl.DateTimeFormat(locale, { hour: "2-digit" }).resolvedOptions().hourCycle; + const naturalCycle = formatProp + ? formatProp === "24" + ? "h23" + : "h12" + : new Intl.DateTimeFormat(locale, { hour: "2-digit" }).resolvedOptions().hourCycle; const hourCycle: "h12" | "h23" = naturalCycle === "h23" || naturalCycle === "h24" ? "h23" : "h12"; const formatter = new Intl.DateTimeFormat(locale, { @@ -211,6 +216,7 @@ export const getTimeInputLocale = ( format === "24" || dayPeriodPosition === "after" ? separatorParts[0]?.value || ":" : separatorParts[separatorParts.length - 1]?.value || ":"; + return { separator, format, From 1af6f4d1fe7a143c4800f0bf2028b12b3eb9a96d Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Thu, 16 Jul 2026 13:29:14 +0200 Subject: [PATCH 3/7] Improved localization and translation --- .../localization/LocalizationPage.tsx | 12 +++++ packages/lib/src/common/variables.ts | 2 + .../lib/src/time-input/TimeInput.stories.tsx | 11 ++++- packages/lib/src/time-input/TimeInput.tsx | 35 ++++++++++---- packages/lib/src/time-input/TimePicker.tsx | 7 ++- .../lib/src/time-input/TimePickerColumn.tsx | 11 ++--- .../lib/src/time-input/TimeSpinButton.tsx | 43 ++++++++--------- packages/lib/src/time-input/utils.ts | 47 +++++++++++++++++-- 8 files changed, 122 insertions(+), 46 deletions(-) diff --git a/apps/website/screens/guidelines/localization/LocalizationPage.tsx b/apps/website/screens/guidelines/localization/LocalizationPage.tsx index 81aebdd1c..7b2ecd52c 100644 --- a/apps/website/screens/guidelines/localization/LocalizationPage.tsx +++ b/apps/website/screens/guidelines/localization/LocalizationPage.tsx @@ -734,6 +734,18 @@ const sections = [ </td> <td>Select time</td> </tr> + <tr> + <td> + <Code>timePeriodAM</Code> + </td> + <td>AM</td> + </tr> + <tr> + <td> + <Code>timePeriodPM</Code> + </td> + <td>PM</td> + </tr> </tbody> </DxcTable> ), diff --git a/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts index f5a58b378..3195db75c 100644 --- a/packages/lib/src/common/variables.ts +++ b/packages/lib/src/common/variables.ts @@ -132,6 +132,8 @@ export const defaultTranslatedComponentLabels = { }, timeInput: { timePickerActionTitle: "Select time", + timePeriodAM: "AM", + timePeriodPM: "PM", }, toast: { clearToastActionTitle: "Clear toast", diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 0b0eefdee..9ea02c699 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -117,11 +117,20 @@ const TimeInput = () => { showSeconds /> </HalstackProvider> - <HalstackProvider localeTag="zh-CN"> + <HalstackProvider + localeTag="zh-CN" + labels={{ + timeInput: { + timePeriodAM: "上午", + timePeriodPM: "下午", + }, + }} + > <DxcTimeInput label="Chinese locale with 12-hour format" helperText="Helper text" onChange={(val) => console.log(val)} + defaultValue="下午 06:30:20" timeFormat="12" showSeconds /> diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 57cb7713b..dac33e49c 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -96,7 +96,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( useEffect(() => { const time = value || defaultValue || undefined; if (time) { - const numberPart = formatInfo.format === "12" ? time.split(" ")[0] : time; + const numberPart = + formatInfo.format === "12" ? time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 1 : 0] : time; if (numberPart) { const [hourStr, minuteStr, secondStr] = numberPart.split(":"); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); @@ -104,7 +105,13 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setSecondValue(secondStr && isNumber(secondStr) ? Number(secondStr) : undefined); } if (formatInfo.format === "12" && time.includes(" ")) { - const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; + const dayPeriod = formatInfo.dayPeriodPosition === "before" ? time.split(" ")[0] : time.split(" ")[1]; + const dayPeriodValue = + dayPeriod === translatedLabels.timeInput.timePeriodAM + ? 0 + : dayPeriod === translatedLabels.timeInput.timePeriodPM + ? 1 + : undefined; setDayPeriodValue(dayPeriodValue); } else { setDayPeriodValue(undefined); @@ -129,7 +136,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ); } }; @@ -151,7 +159,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -230,7 +239,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -278,7 +288,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -322,7 +333,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -377,7 +389,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -426,7 +439,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } @@ -472,7 +486,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( showSeconds, formatInfo.format, formatInfo.separator, - formatInfo.dayPeriodPosition + formatInfo.dayPeriodPosition, + translatedLabels ) ); } diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 36ce6fd99..5e690596e 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -1,8 +1,9 @@ import styled from "@emotion/styled"; import { TimePickerPropsType } from "./types"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import TimePickerColumn from "./TimePickerColumn"; import { handleColumnKeyDown } from "./utils"; +import { HalstackLanguageContext } from "../HalstackContext"; // Array to be used in seconds and minutes. const STEP = 5; const ARRAY_OF_60 = Array.from({ length: 60 / STEP }, (_, index) => index * STEP); @@ -31,6 +32,8 @@ const TimePicker = ({ const [dayPeriodToFocus, setDayPeriodToFocus] = useState(dayPeriod ?? 0); const totalHours = timeFormat === "12" ? 12 : 24; + const translatedLabels = useContext(HalstackLanguageContext).labels; + const onPickerSelect = (value: number, type: "hour" | "minute" | "second" | "dayPeriod") => { const hourVal = type === "hour" ? value : (hourValue ?? (timeFormat === "12" ? 1 : 0)); const minuteVal = type === "minute" ? value : (minuteValue ?? 0); @@ -83,6 +86,7 @@ const TimePicker = ({ onPickerSelect(value, "dayPeriod") ) } + translatedLabels={translatedLabels} /> )} <TimePickerColumn @@ -163,6 +167,7 @@ const TimePicker = ({ onPickerSelect(value, "dayPeriod") ) } + translatedLabels={translatedLabels} /> )} </TimePickerContainer> diff --git a/packages/lib/src/time-input/TimePickerColumn.tsx b/packages/lib/src/time-input/TimePickerColumn.tsx index 20fa97a9b..a5e953726 100644 --- a/packages/lib/src/time-input/TimePickerColumn.tsx +++ b/packages/lib/src/time-input/TimePickerColumn.tsx @@ -1,7 +1,8 @@ import styled from "@emotion/styled"; import DxcContainer from "../container/Container"; import DxcFlex from "../flex/Flex"; -import { pad } from "./utils"; +import { pad, returnDayPeriod } from "./utils"; +import { TranslatedLabels } from "../common/variables"; const TimePickerOption = styled.li<{ selected: boolean; @@ -46,10 +47,6 @@ const returnHourBasedOnIndex = (index: number, dataType: "hour" | "minute" | "se } }; -const returnDayPeriod = (value: number) => { - return value === 0 ? "AM" : value === 1 ? "PM" : ""; -}; - const TimePickerColumn = ({ valuesArray, id, @@ -59,6 +56,7 @@ const TimePickerColumn = ({ dataType, onClick, onKeyboardEvent, + translatedLabels, }: { valuesArray: number[]; id?: string; @@ -68,6 +66,7 @@ const TimePickerColumn = ({ dataType: "hour" | "minute" | "second" | "dayPeriod"; onClick: (value: number) => void; onKeyboardEvent: (event: React.KeyboardEvent, value: number) => void; + translatedLabels?: TranslatedLabels; }) => { return ( <DxcContainer @@ -95,7 +94,7 @@ const TimePickerColumn = ({ > {dataType !== "dayPeriod" ? pad(returnHourBasedOnIndex(optionValue, dataType)) - : returnDayPeriod(returnHourBasedOnIndex(optionValue, dataType))} + : returnDayPeriod(returnHourBasedOnIndex(optionValue, dataType), translatedLabels)} </TimePickerOption> ))} </DxcFlex> diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index ccc1c2c53..6f35b3a80 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -1,7 +1,8 @@ import styled from "@emotion/styled"; import { TimeSpinButtonPropsType } from "./types"; -import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; -import { handleKeyDown, pad, returnDayPeriod } from "./utils"; +import { forwardRef, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { handleKeyDown, pad, returnDayPeriod, generateDisplayValue } from "./utils"; +import { HalstackLanguageContext } from "../HalstackContext"; const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean; disabled: boolean }>` caret-color: transparent; @@ -27,21 +28,6 @@ const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean; disabled: box-sizing: border-box; `; -const generateDisplayValue = ( - dataType: "hour" | "minute" | "second" | "dayPeriod" | undefined, - value: number | undefined, - placeholder: string, - maxValue: number -) => { - let displayValue; - if (dataType === "dayPeriod") { - displayValue = value != null ? returnDayPeriod(value) : placeholder; - } else { - displayValue = value != null ? value.toString().padStart(maxValue.toString().length, "0") : placeholder; - } - return displayValue; -}; - const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( ( { @@ -63,6 +49,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( ) => { const [innerValue, setInnerValue] = useState<number | undefined>(value); const spanRef = useRef<HTMLSpanElement | null>(null); + const translatedLabels = useContext(HalstackLanguageContext).labels; const placeholder = useMemo(() => { switch (dataType) { @@ -86,18 +73,24 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( useEffect(() => { if (!spanRef.current) return; if (!isControlled) { - spanRef.current.textContent = generateDisplayValue(dataType, innerValue, placeholder, maxValue); + spanRef.current.textContent = generateDisplayValue( + dataType, + innerValue, + placeholder, + maxValue, + translatedLabels + ); } else { - spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue, translatedLabels); } - }, [innerValue, placeholder, maxValue, dataType, isControlled]); + }, [innerValue, placeholder, maxValue, dataType, isControlled, translatedLabels]); useEffect(() => { setInnerValue(value); if (spanRef.current) { - spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue, translatedLabels); } - }, [value, placeholder, maxValue, dataType]); + }, [value, placeholder, maxValue, dataType, translatedLabels]); // Values used to track the raw input before it's resolved to a valid value. const rawInput = useRef<string>(""); @@ -116,7 +109,11 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( role="spinbutton" aria-valuenow={innerValue ?? undefined} aria-valuetext={ - innerValue != null ? (dataType === "dayPeriod" ? returnDayPeriod(innerValue) : pad(innerValue)) : "Empty" + innerValue != null + ? dataType === "dayPeriod" + ? returnDayPeriod(innerValue, translatedLabels) + : pad(innerValue) + : "Empty" } aria-valuemin={minValue} aria-valuemax={maxValue} diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 5ae44f08c..653a0f94d 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -1,10 +1,16 @@ +import { TranslatedLabels } from "../common/variables"; + export const pad = (num?: number) => { if (num === undefined) return ""; return num < 10 ? `0${num}` : `${num}`; }; -export const returnDayPeriod = (value?: number) => { - return value === 0 ? "AM" : value === 1 ? "PM" : ""; +export const returnDayPeriod = (value?: number, translatedLabels?: TranslatedLabels) => { + return value === 0 + ? (translatedLabels?.timeInput.timePeriodAM ?? "AM") + : value === 1 + ? (translatedLabels?.timeInput.timePeriodPM ?? "PM") + : ""; }; const resolveValue = (value: string | number, maxValue: number, minValue: number) => { @@ -117,6 +123,31 @@ export const handleKeyDown = ( } }; +/** + * + * @param dataType The type of time unit (hour, minute, second, dayPeriod) + * @param value The current value of the time unit + * @param placeholder The placeholder text to display when the value is undefined + * @param maxValue The maximum value for the time unit + * @param translatedLabels The translated labels for day periods + * @returns The display value for the spin button, formatted according to the data type and value provided. If the value is undefined, the placeholder will be displayed instead. + */ +export const generateDisplayValue = ( + dataType: "hour" | "minute" | "second" | "dayPeriod" | undefined, + value: number | undefined, + placeholder: string, + maxValue: number, + translatedLabels?: TranslatedLabels +) => { + let displayValue; + if (dataType === "dayPeriod") { + displayValue = value != null ? returnDayPeriod(value, translatedLabels) : placeholder; + } else { + displayValue = value != null ? value.toString().padStart(maxValue.toString().length, "0") : placeholder; + } + return displayValue; +}; + export const generateEventValue = ( hour: number | undefined, minute: number | undefined, @@ -125,16 +156,17 @@ export const generateEventValue = ( showSeconds: boolean | undefined, timeFormat: "12" | "24" | undefined, separator: string, - timePeriodPosition: "before" | "after" + timePeriodPosition: "before" | "after", + translatedLabels: TranslatedLabels ) => { if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { return ""; } // consider dayperiod position for 12-hour format otherwise ignore it if (timeFormat === "12" && timePeriodPosition === "before") { - return `${returnDayPeriod(dayPeriod)} ${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""}`; + return `${returnDayPeriod(dayPeriod, translatedLabels)} ${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""}`; } else if (timeFormat === "12" && timePeriodPosition === "after") { - return `${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""} ${returnDayPeriod(dayPeriod)}`; + return `${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""} ${returnDayPeriod(dayPeriod, translatedLabels)}`; } else { return `${pad(hour)}${separator}${pad(minute)}${showSeconds ? `${separator}${pad(second)}` : ""}`; } @@ -183,6 +215,11 @@ export const handleColumnKeyDown = ( } }; +/** + * @param locale The locale string (e.g., "en-US") used to determine time formatting conventions. + * @param formatProp Optional. Specifies whether to use "12" or "24" hour format. + * @returns An object containing the time separator, preferred format, and day period position. + */ export const getTimeInputLocale = ( locale?: string, formatProp?: "12" | "24" From 541586d2fbeed8471a7801551d758f2df1c8cf1e Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Thu, 16 Jul 2026 13:43:47 +0200 Subject: [PATCH 4/7] Test reverted for now --- packages/lib/src/time-input/TimeInput.stories.tsx | 11 +---------- packages/lib/src/time-input/TimeInput.tsx | 4 +++- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 9ea02c699..0b0eefdee 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -117,20 +117,11 @@ const TimeInput = () => { showSeconds /> </HalstackProvider> - <HalstackProvider - localeTag="zh-CN" - labels={{ - timeInput: { - timePeriodAM: "上午", - timePeriodPM: "下午", - }, - }} - > + <HalstackProvider localeTag="zh-CN"> <DxcTimeInput label="Chinese locale with 12-hour format" helperText="Helper text" onChange={(val) => console.log(val)} - defaultValue="下午 06:30:20" timeFormat="12" showSeconds /> diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index dac33e49c..4ae0d11d9 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -99,13 +99,15 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const numberPart = formatInfo.format === "12" ? time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 1 : 0] : time; if (numberPart) { + console.log("numberPart", numberPart); const [hourStr, minuteStr, secondStr] = numberPart.split(":"); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); setMinuteValue(minuteStr && isNumber(minuteStr) ? Number(minuteStr) : undefined); setSecondValue(secondStr && isNumber(secondStr) ? Number(secondStr) : undefined); } if (formatInfo.format === "12" && time.includes(" ")) { - const dayPeriod = formatInfo.dayPeriodPosition === "before" ? time.split(" ")[0] : time.split(" ")[1]; + const dayPeriod = time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 0 : 1]; + console.log("dayPeriod", dayPeriod); const dayPeriodValue = dayPeriod === translatedLabels.timeInput.timePeriodAM ? 0 From e2e2979be8a34c6ac773ae9e0ddee85f84b96bde Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Thu, 16 Jul 2026 13:45:03 +0200 Subject: [PATCH 5/7] removed console.log --- packages/lib/src/time-input/TimeInput.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 4ae0d11d9..acbdcddb0 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -99,7 +99,6 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const numberPart = formatInfo.format === "12" ? time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 1 : 0] : time; if (numberPart) { - console.log("numberPart", numberPart); const [hourStr, minuteStr, secondStr] = numberPart.split(":"); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); setMinuteValue(minuteStr && isNumber(minuteStr) ? Number(minuteStr) : undefined); @@ -107,7 +106,6 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } if (formatInfo.format === "12" && time.includes(" ")) { const dayPeriod = time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 0 : 1]; - console.log("dayPeriod", dayPeriod); const dayPeriodValue = dayPeriod === translatedLabels.timeInput.timePeriodAM ? 0 From 1429fe859b9fd6657ae78b743fa008c29993e612 Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Tue, 21 Jul 2026 11:55:58 +0200 Subject: [PATCH 6/7] Documentation updated to mention the locale in affected props. --- .../screens/components/date-input/code/DateInputCodePage.tsx | 2 +- .../screens/components/time-input/code/TimeInputCodePage.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/website/screens/components/date-input/code/DateInputCodePage.tsx b/apps/website/screens/components/date-input/code/DateInputCodePage.tsx index 766731813..87fcd5bc4 100644 --- a/apps/website/screens/components/date-input/code/DateInputCodePage.tsx +++ b/apps/website/screens/components/date-input/code/DateInputCodePage.tsx @@ -101,7 +101,7 @@ const sections = [ The format in which the date value will be displayed. User must follow this format when editing the value or it will be considered as an invalid date. In this case, the <Code>onBlur</Code> and{" "} <Code>onChange</Code> functions will be called with an internal error as a parameter reporting the - situation. + situation. If a locale is provided, it will override this setting's default value. </td> <td> <TableCode>'dd-MM-yyyy'</TableCode> diff --git a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx index 2980b878c..7148d8403 100644 --- a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -198,7 +198,10 @@ const sections = [ <td> <TableCode>'12' | '24'</TableCode> </td> - <td>Time format of the input. It can be either 12 or 24.</td> + <td> + Time format of the input. It can be either 12 or 24. If a locale is provided, it will override this + setting's default value. + </td> <td> <TableCode>'12'</TableCode> </td> From 7cdc288a1c9b0b720f6bc4f88196dedc342dbf82 Mon Sep 17 00:00:00 2001 From: jialecl <jialestrabajos@gmail.com> Date: Wed, 22 Jul 2026 12:53:47 +0200 Subject: [PATCH 7/7] Fixed bugs based on comments --- packages/lib/src/time-input/TimeInput.tsx | 31 ++++++++++++++++++----- packages/lib/src/time-input/utils.ts | 26 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index acbdcddb0..b0fbeb2c8 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -10,7 +10,7 @@ import DxcFlex from "../flex/Flex"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcPopover from "../popover/Popover"; import TimePicker from "./TimePicker"; -import { generateEventValue, getTimeInputLocale } from "./utils"; +import { buildTimeRegex, generateEventValue, getTimeInputLocale } from "./utils"; import ErrorMessage from "../styles/forms/ErrorMessage"; const sizes = { @@ -92,14 +92,17 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const isControlled = value !== undefined; const languageContext = useContext(HalstackLanguageContext); const translatedLabels = languageContext.labels; - const formatInfo = useMemo(() => getTimeInputLocale(languageContext.locale, timeFormat), [languageContext.locale]); + const formatInfo = useMemo( + () => getTimeInputLocale(languageContext.locale, timeFormat), + [languageContext.locale, timeFormat] + ); useEffect(() => { const time = value || defaultValue || undefined; if (time) { const numberPart = formatInfo.format === "12" ? time.split(" ")[formatInfo.dayPeriodPosition === "before" ? 1 : 0] : time; if (numberPart) { - const [hourStr, minuteStr, secondStr] = numberPart.split(":"); + const [hourStr, minuteStr, secondStr] = numberPart.split(formatInfo.separator); setHourValue(hourStr && isNumber(hourStr) ? Number(hourStr) : undefined); setMinuteValue(minuteStr && isNumber(minuteStr) ? Number(minuteStr) : undefined); setSecondValue(secondStr && isNumber(secondStr) ? Number(secondStr) : undefined); @@ -167,11 +170,16 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( }; const validateTimeValue = (value: string) => { - const timeRegex = - formatInfo.format === "12" - ? /^(0?[1-9]|1[0-2]):[0-5][0-9](?::[0-5][0-9])?\s?(AM|PM)$/i - : /^([01]?[0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/; + const timeRegex = buildTimeRegex( + formatInfo.format, + formatInfo.separator, + showSeconds, + formatInfo.dayPeriodPosition, + translatedLabels.timeInput.timePeriodAM || "AM", + translatedLabels.timeInput.timePeriodPM || "PM" + ); if (!timeRegex.test(value)) { + console.log("Invalid time format"); return "Invalid time format"; } if ( @@ -299,6 +307,15 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( minuteRef.current.focus(); } }} + onPrevious={() => { + if ( + formatInfo.format === "12" && + formatInfo.dayPeriodPosition === "before" && + dayPeriodRef.current + ) { + dayPeriodRef.current.focus(); + } + }} ref={hourRef} /> <ColonContainer>{formatInfo.separator}</ColonContainer> diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 653a0f94d..ce8a9329c 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -260,3 +260,29 @@ export const getTimeInputLocale = ( dayPeriodPosition, }; }; + +const escapeStringForRegex = (str: string) => { + return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +}; + +export const buildTimeRegex = ( + format: "12" | "24", + separator: string, + hasSeconds: boolean, + dayPeriodPosition: "before" | "after", + amLabel: string, + pmLabel: string +): RegExp => { + const escapedSeparator = escapeStringForRegex(separator); + const hourPattern = format === "12" ? "(0?[1-9]|1[0-2])" : "([01]?[0-9]|2[0-3])"; + const minutePattern = "[0-5][0-9]"; + const secondPattern = hasSeconds ? `${escapedSeparator}[0-5][0-9]` : ""; + const dayPeriodPattern = + format === "12" ? `(?:${escapeStringForRegex(amLabel)}|${escapeStringForRegex(pmLabel)})` : ""; + const timePattern = + dayPeriodPosition === "before" + ? `${dayPeriodPattern}\\s?${hourPattern}${escapedSeparator}${minutePattern}${secondPattern}` + : `${hourPattern}${escapedSeparator}${minutePattern}${secondPattern}\\s?${dayPeriodPattern}`; + + return new RegExp(`^${timePattern}$`); +};