From 65c7db769407c36175abb123db2a3ac278079a95 Mon Sep 17 00:00:00 2001 From: jialecl Date: Mon, 13 Jul 2026 14:46:29 +0200 Subject: [PATCH 1/2] 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/2] 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,