From 3193f0e13e308f9872ebdab091efa64a273626b3 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Mon, 20 Apr 2026 13:56:38 +0200 Subject: [PATCH 01/18] Time component first commit --- .../lib/src/time-input/TimeInput.stories.tsx | 38 +++ packages/lib/src/time-input/TimeInput.tsx | 257 ++++++++++++++++++ packages/lib/src/time-input/TimePicker.tsx | 112 ++++++++ .../lib/src/time-input/TimeSpinButton.tsx | 103 +++++++ packages/lib/src/time-input/types.ts | 121 +++++++++ packages/lib/src/time-input/utils.ts | 111 ++++++++ 6 files changed, 742 insertions(+) create mode 100644 packages/lib/src/time-input/TimeInput.stories.tsx create mode 100644 packages/lib/src/time-input/TimeInput.tsx create mode 100644 packages/lib/src/time-input/TimePicker.tsx create mode 100644 packages/lib/src/time-input/TimeSpinButton.tsx create mode 100644 packages/lib/src/time-input/types.ts create mode 100644 packages/lib/src/time-input/utils.ts diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx new file mode 100644 index 000000000..69b786c91 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -0,0 +1,38 @@ +import DxcTimeInput from "./TimeInput"; +import ExampleContainer from "../../.storybook/components/ExampleContainer"; +import Title from "../../.storybook/components/Title"; +import { Meta, StoryObj } from "@storybook/react-vite"; +import preview from "../../.storybook/preview"; +import disabledRules from "../../test/accessibility/rules/common/disabledRules"; + +export default { + title: "Time Input", + component: DxcTimeInput, + parameters: { + a11y: { + config: { + rules: [ + ...(preview?.parameters?.a11y?.config?.rules || []), + ...disabledRules.map((ruleId) => ({ id: ruleId, reviewOnFail: true })), + ], + }, + }, + }, +} satisfies Meta; + +const TimeInput = () => ( + <> + + <ExampleContainer> + <DxcTimeInput label="Time" helperText="Helper text" /> + <DxcTimeInput label="Time" helperText="Helper text" showSeconds /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + </ExampleContainer> + </> +); + +type Story = StoryObj<typeof DxcTimeInput>; + +export const Chromatic: Story = { + render: TimeInput, +}; diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx new file mode 100644 index 000000000..a68cb92b8 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -0,0 +1,257 @@ +import styled from "@emotion/styled"; +import inputStylesByState from "../styles/forms/inputStylesByState"; +import { calculateWidth } from "../text-input/utils"; +import TimeInputPropsType, { RefType } from "./types"; +import { forwardRef, useContext, useEffect, useId, useRef, useState } from "react"; +import { HalstackLanguageContext } from "../HalstackContext"; +import Label from "../styles/forms/Label"; +import HelperText from "../styles/forms/HelperText"; +import TimeSpinButton from "./TimeSpinButton"; +import DxcFlex from "../flex/Flex"; +import DxcActionIcon from "../action-icon/ActionIcon"; +import DxcPopover from "../popover/Popover"; +import { handleClearActionOnClick } from "./utils"; +import TimePicker from "./TimePicker"; + +const TimeInputContainer = styled.div<{ + size: TimeInputPropsType["size"]; +}>` + box-sizing: border-box; + display: flex; + flex-direction: column; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-regular); + color: var(--color-fg-neutral-dark); + width: ${({ size }) => calculateWidth(undefined, size)}; +`; + +const TimeInputField = styled.div<{ + disabled: Required<TimeInputPropsType>["disabled"]; + error: boolean; + readOnly: Required<TimeInputPropsType>["readOnly"]; +}>` + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + height: var(--height-m); + padding: var(--spacing-padding-none) var(--spacing-padding-xs); + ${({ disabled, error, readOnly }) => inputStylesByState(disabled, error, readOnly)} +`; + +const ColonContainer = styled.span` + padding: 0; + color: var(--color-fg-neutral-strong); +`; + +const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( + ( + { + ariaLabel = "Text input", + clearable = false, + defaultValue = "", + disabled = false, + error, + helperText, + label, + name = "", + optional = false, + readOnly = false, + onBlur, + onChange, + showSeconds = false, + size = "medium", + tabIndex = 0, + timeFormat = "12", + value, + }, + ref + ) => { + const inputId = `input-${useId()}`; + const [hourValue, setHourValue] = useState<number | undefined>(undefined); + const [minuteValue, setMinuteValue] = useState<number | undefined>(undefined); + const [secondValue, setSecondValue] = useState<number | undefined>(undefined); + const [dayPeriod, setDayPeriod] = useState<number | undefined>(undefined); + const [isOpen, setIsOpen] = useState(false); + const hourRef = useRef<HTMLSpanElement>(null); + const minuteRef = useRef<HTMLSpanElement>(null); + const secondRef = useRef<HTMLSpanElement>(null); + const dayPeriodRef = useRef<HTMLSpanElement>(null); + // const isControlled = useRef(value !== undefined); + const translatedLabels = useContext(HalstackLanguageContext); + + useEffect(() => { + const time = value || defaultValue; + if (time) { + const [hour, minute, second] = time.split(":").map(Number); + setHourValue(hour); + setMinuteValue(minute); + setSecondValue(second); + if (timeFormat === "12") { + setDayPeriod(hour && hour >= 12 ? 1 : 0); + } + } + }, [value, defaultValue]); + + // useEffect(() => { + // let valueToEmit = `${hourValue}:${minuteValue}`; + // if (showSeconds) { + // valueToEmit += `:${secondValue}`; + // } + // if (timeFormat === "12") { + // valueToEmit += dayPeriod; + // } + // if (typeof onChange === "function") { + // onChange(valueToEmit); + // } + // }, [hourValue, minuteValue, secondValue, dayPeriod]); + + return ( + <> + <DxcPopover + popoverContent={ + <TimePicker + onSelecthours={setHourValue} + onSelectMinutes={setMinuteValue} + onSelectSeconds={setSecondValue} + timeFormat={timeFormat} + showSeconds={showSeconds} + /> + } + asChild + isOpen={isOpen} + onClose={() => { + setIsOpen(false); + }} + align="end" + > + <TimeInputContainer + size={size} + ref={ref} + onBlur={() => { + if (typeof onBlur === "function") { + onBlur({ + value: `${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}`, + }); + } + }} + onChange={() => { + if (typeof onChange === "function") { + onChange( + `${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}` + ); + } + }} + > + <Label disabled={disabled} hasMargin={!helperText} htmlFor={inputId}> + {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} + </Label> + {helperText && ( + <HelperText disabled={disabled} hasMargin> + {helperText} + </HelperText> + )} + <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> + <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center"> + <DxcFlex gap="var(--spacing-gap-xxs)" alignItems="center"> + <TimeSpinButton + value={hourValue} + minValue={timeFormat === "12" ? 1 : 0} + maxValue={timeFormat === "12" ? 12 : 23} + inputId={inputId} + tabIndex={tabIndex} + dataType="hour" + interactive={!disabled && !readOnly} + onComplete={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} + ref={hourRef} + /> + <ColonContainer>:</ColonContainer> + <TimeSpinButton + value={minuteValue} + minValue={0} + maxValue={59} + inputId={inputId} + tabIndex={tabIndex} + dataType="minute" + interactive={!disabled && !readOnly} + onComplete={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + ref={minuteRef} + /> + {showSeconds && ( + <> + <ColonContainer>:</ColonContainer> + <TimeSpinButton + value={secondValue} + minValue={0} + maxValue={59} + inputId={inputId} + tabIndex={tabIndex} + dataType="second" + interactive={!disabled && !readOnly} + onComplete={() => { + if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + ref={secondRef} + /> + </> + )} + </DxcFlex> + {timeFormat === "12" && ( + <TimeSpinButton + value={dayPeriod} + minValue={0} + maxValue={1} + inputId={inputId} + tabIndex={tabIndex} + dataType="dayPeriod" + interactive={!disabled && !readOnly} + ref={dayPeriodRef} + /> + )} + </DxcFlex> + <span> + {clearable && ( + <DxcActionIcon + size="xsmall" + icon="close" + onClick={() => handleClearActionOnClick()} + tabIndex={tabIndex} + title={!disabled ? translatedLabels.textInput.clearFieldActionTitle : undefined} + /> + )} + <DxcActionIcon + size="xsmall" + disabled={disabled} + icon="schedule" + title="Select time" + onClick={() => setIsOpen(true)} + /> + </span> + </TimeInputField> + </TimeInputContainer> + </DxcPopover> + <input + aria-label={ariaLabel} + type="hidden" + name={name} + value={`${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}`} + /> + </> + ); + } +); + +export default DxcTimeInput; diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx new file mode 100644 index 000000000..e9be702d1 --- /dev/null +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -0,0 +1,112 @@ +import styled from "@emotion/styled"; +import { TimePickerPropsType } from "./types"; +import DxcContainer from "../container/Container"; +import DxcFlex from "../flex/Flex"; + +const TimePickerContainer = styled.div` + display: flex; + height: 200px; + gap: var(--spacing-gap-m); +`; + +const TimePickerOption = styled.button<{ + selected: boolean; +}>` + display: inline-flex; + justify-content: center; + align-items: center; + width: 32px; + height: var(--height-m); + padding: 0; + border: none; + border-radius: var(--border-radius-xl); + cursor: pointer; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-regular); + background-color: ${(props) => (props.selected ? "var(--color-bg-primary-strong);" : "transparent")}; + color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; + + &:focus { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: calc(var(--border-width-m) * -1); + } + &:hover { + background-color: ${(props) => + props.selected ? "var(--color-bg-primary-strong);" : "var(--color-bg-primary-lighter);"}; + color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; + } + &:active { + background-color: var(--color-bg-primary-stronger); + color: var(--color-fg-neutral-bright); + } +`; + +const TimePicker = ({ + onSelecthours, + onSelectMinutes, + onSelectSeconds, + onSelectDayPeriod, + timeFormat, + showSeconds, +}: TimePickerPropsType) => { + const hours = timeFormat === "12" ? 12 : 24; + return ( + <TimePickerContainer> + <DxcContainer maxHeight="100%" overflow="auto"> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> + {Array.from({ length: hours }, (_, index) => ( + <TimePickerOption + key={index} + selected={false} + onClick={() => onSelecthours(index + 1 === 24 ? 0 : index + 1)} + > + {index + 1 === 24 ? "00" : index + 1 < 10 ? `0${index + 1}` : index + 1} + </TimePickerOption> + ))} + </DxcFlex> + </DxcContainer> + <DxcContainer maxHeight="100%" overflow="auto"> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> + {Array.from({ length: 60 }, (_, index) => ( + <TimePickerOption key={index} selected={false} onClick={() => onSelectMinutes(index)}> + {index < 10 ? `0${index}` : index} + </TimePickerOption> + ))} + </DxcFlex> + </DxcContainer> + {showSeconds && ( + <DxcContainer maxHeight="100%" overflow="auto"> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> + {Array.from({ length: 60 }, (_, index) => ( + <TimePickerOption key={index} selected={false} onClick={() => onSelectSeconds(index)}> + {index < 10 ? `0${index}` : index} + </TimePickerOption> + ))} + </DxcFlex> + </DxcContainer> + )} + {timeFormat === "12" && ( + <DxcContainer maxHeight="100%" overflow="auto"> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> + {["AM", "PM"].map((period) => ( + <TimePickerOption + key={period} + selected={false} + onClick={() => { + if (typeof onSelectDayPeriod === "function") { + onSelectDayPeriod(period === "AM"); + } + }} + > + {period} + </TimePickerOption> + ))} + </DxcFlex> + </DxcContainer> + )} + </TimePickerContainer> + ); +}; + +export default TimePicker; diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx new file mode 100644 index 000000000..44d6b205c --- /dev/null +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -0,0 +1,103 @@ +import styled from "@emotion/styled"; +import { TimeSpinButtonPropsType } from "./types"; +import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; +import { handleKeyDown } from "./utils"; + +const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean }>` + caret-color: transparent; + color: ${(props) => (props.isPlaceholder ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + &:focus { + background-color: var(--color-bg-primary-lighter); + outline: none; + } +`; + +const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( + ( + { value, minValue, maxValue, inputId, tabIndex, dataType, interactive, onChange, onComplete, onNext, onPrevious }, + ref + ) => { + const [innerValue, setInnerValue] = useState<number | undefined>(value); + let spanRef = useRef<HTMLSpanElement | null>(null); + + const placeholder = useMemo(() => { + switch (dataType) { + case "hour": + return "hh"; + case "minute": + return "mm"; + case "second": + return "ss"; + case "dayPeriod": + return "aa"; + default: + return "--"; + } + }, [dataType]); + + useEffect(() => { + if (!spanRef.current) return; + let displayValue; + if (dataType === "dayPeriod") { + displayValue = innerValue === 0 ? "AM" : innerValue === 1 ? "PM" : placeholder; + } else { + displayValue = + innerValue != null ? innerValue.toString().padStart(maxValue.toString().length, "0") : placeholder; + } + spanRef.current.textContent = displayValue; + }, [innerValue, placeholder, maxValue, dataType]); + + // Value used to track the raw input before it's resolved to a valid value. + const rawInput = useRef<string>(""); + const newDigit = useRef<string>(""); + + const handleBlur = () => { + rawInput.current = ""; + }; + return ( + <TimeSpinButtonContainer + ref={(node) => { + spanRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + role="spinbutton" + aria-valuenow={innerValue ?? undefined} + aria-valuetext={innerValue != null ? String(innerValue) : "Empty"} + aria-valuemin={minValue} + aria-valuemax={maxValue} + aria-labelledby={inputId} + contentEditable={interactive ? "plaintext-only" : "false"} + inputMode={dataType !== "dayPeriod" ? "numeric" : undefined} + tabIndex={tabIndex} + data-type={dataType} + data-placeholder={innerValue == null} + isPlaceholder={innerValue == null} + onKeyDown={(event) => + handleKeyDown( + event, + rawInput, + newDigit, + spanRef, + setInnerValue, + innerValue, + placeholder, + maxValue, + minValue, + dataType === "dayPeriod", + onChange, + onComplete, + onNext, + onPrevious + ) + } + onBlur={handleBlur} + /> + ); + } +); + +export default TimeSpinButton; diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts new file mode 100644 index 000000000..c7edad0e2 --- /dev/null +++ b/packages/lib/src/time-input/types.ts @@ -0,0 +1,121 @@ +type Props = { + /** + * Specifies a string to be used as the name for the textInput element when no `label` is provided. + */ + ariaLabel?: string; + /** + * HTML autocomplete attribute. Lets the user specify if any permission the user agent has to provide automated assistance in filling out the input value. + * Its value must be one of all the possible values of the HTML autocomplete attribute: 'on', 'off', 'email', 'username', 'new-password', ... + */ + autocomplete?: string; + /** + * If true, the input will have an action to clear the entered value. + */ + clearable?: boolean; + /** + * Initial value of the input, only when it is uncontrolled. + */ + defaultValue?: string; + /** + * If true, the component will be disabled. + */ + disabled?: boolean; + /** + * If it is a defined value and also a truthy string, the component will + * change its appearance, showing the error below the input component. If + * the defined value is an empty string, it will reserve a space below + * the component for a future error, but it would not change its look. In + * case of being undefined or null, both the appearance and the space for + * the error message would not be modified. + */ + error?: string; + /** + * Helper text to be placed above the input. + */ + helperText?: string; + /** + * Text to be placed above the input. This label will be used as the aria-label attribute of the list of suggestions. + */ + label?: string; + /** + * Name attribute of the input element. + */ + name?: string; + /** + * This function will be called when the input element loses the focus. + * An object including the input value and the error (if the value + * entered is not valid) will be passed to this function. If there is no error, + * error will not be defined. + */ + onBlur?: (val: { value: string; error?: string }) => void; + /** + * This function will be called when the user types within the input + * element of the component. An object including the current value and + * the error (if the value entered is not valid) will be passed to this + * function. If there is no error, error will not be defined. + */ + onChange?: (value: string) => void; + /** + * If true, the input will be optional, showing '(Optional)' + * next to the label. Otherwise, the field will be considered required and an error will be + * passed as a parameter to the OnBlur and onChange functions when it has + * not been filled. + */ + optional?: boolean; + /** + * If true, the component will not be mutable, meaning the user can not edit the control. + * In addition, the clear action will not be displayed even if the flag is set to true + * and the custom action will not execute its onClick event. + */ + readOnly?: boolean; + /** + * If true, the input will display seconds. + */ + showSeconds?: boolean; + /** + * Size of the component. + */ + size?: "small" | "medium" | "large" | "fillParent"; + /** + * Value of the tabindex attribute. + */ + tabIndex?: number; + /** + * Time format of the input. It can be either 12 or 24. + */ + timeFormat?: "12" | "24"; + /** + * Value of the input. If undefined, the component will be uncontrolled and the value will be managed internally by the component. + */ + value?: string; +}; + +/** + * Reference to the component. + */ +export type RefType = HTMLDivElement; + +export type TimeSpinButtonPropsType = { + value: number | undefined; + minValue: number; + maxValue: number; + inputId: string; + tabIndex: number; + dataType?: "hour" | "minute" | "second" | "dayPeriod"; + interactive: boolean; + onComplete?: () => void; + onChange?: (value: number | undefined) => void; + onNext?: () => void; + onPrevious?: () => void; +}; + +export type TimePickerPropsType = { + onSelecthours: (hours: number) => void; + onSelectMinutes: (minutes: number) => void; + onSelectSeconds: (seconds: number) => void; + onSelectDayPeriod?: (isAM: boolean) => void; + timeFormat: "12" | "24"; + showSeconds: boolean; +}; + +export default Props; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts new file mode 100644 index 000000000..b1c88cb59 --- /dev/null +++ b/packages/lib/src/time-input/utils.ts @@ -0,0 +1,111 @@ +const resolveValue = (value: string | number, maxValue: number, minValue: number) => { + const input = typeof value === "string" ? parseInt(value, 10) : value; + if (input > maxValue) { + return maxValue; + } else if (value.toString().length > 1 && input < minValue) { + return minValue; + } else { + return input; + } +}; + +const checkCompletion = (value: string, maxValue: number) => { + const maxValueFirstDigit = maxValue.toString()[0]; + if ( + value.length === 1 && + maxValueFirstDigit !== undefined && + value[0] !== undefined && + parseInt(value[0], 10) > parseInt(maxValueFirstDigit, 10) + ) { + return true; + } + return value.length >= maxValue.toString().length; +}; + +export const handleKeyDown = ( + event: React.KeyboardEvent<HTMLSpanElement>, + rawInput: React.MutableRefObject<string>, + newDigit: React.MutableRefObject<string>, + spanRef: React.MutableRefObject<HTMLSpanElement | null>, + setInnerValue: React.Dispatch<React.SetStateAction<number | undefined>>, + innerValue: number | undefined, + placeholder: string, + maxValue: number, + minValue: number, + isDayPeriod?: boolean, + onChange?: (value: number | undefined) => void, + onComplete?: () => void, + onNext?: () => void, + onPrevious?: () => void +) => { + const input = event.currentTarget; + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + rawInput.current = rawInput.current.slice(0, -1); + if (!spanRef.current) return; + if (rawInput.current === "") { + setInnerValue(undefined); + spanRef.current.textContent = placeholder; + } else { + const numericValue = parseInt(rawInput.current, 10); + setInnerValue(numericValue); + spanRef.current.textContent = rawInput.current; + } + return; + } + + if (!["Tab", "Enter"].includes(event.key)) event.preventDefault(); + + if (/^\d$/.test(event.key) && !isDayPeriod) { + // Number input + newDigit.current = event.key; + rawInput.current = (rawInput.current + newDigit.current).slice(-maxValue.toString().length); + const newValue = resolveValue(rawInput.current, maxValue, minValue); + // If the raw input has reached the max length or exceeds the max value with the new digit, consider it complete and move to the next field. + if (checkCompletion(rawInput.current, maxValue)) { + const newStringValue = newValue.toString(); + // Pad with zeros if the new value is shorter than the max value length. + if (newStringValue.length < maxValue.toString().length) { + rawInput.current = "0" + newStringValue; + } else { + rawInput.current = newStringValue; + } + if (typeof onComplete === "function") { + onComplete(); + } + } + input.textContent = rawInput.current; + setInnerValue(newValue); + if (typeof onChange === "function") { + onChange(newValue); + } + } else if (event.key === "ArrowUp") { + if (innerValue == null || innerValue >= maxValue) { + setInnerValue(minValue); + } else { + const newValue = resolveValue(innerValue + 1, maxValue, minValue); + setInnerValue(newValue); + } + } else if (event.key === "ArrowDown") { + if (innerValue == null || innerValue <= minValue) { + setInnerValue(maxValue); + } else { + const newValue = resolveValue(innerValue - 1, maxValue, minValue); + setInnerValue(newValue); + } + } else if (isDayPeriod && /[apAP01]/.test(event.key)) { + // AM/PM input + const isAM = /[aA0]/.test(event.key); + setInnerValue(isAM ? 0 : 1); + } + + if (event.key === "ArrowRight" && typeof onNext === "function") { + onNext(); + } else if (event.key === "ArrowLeft" && typeof onPrevious === "function") { + onPrevious(); + } +}; + +export const handleClearActionOnClick = () => { + console.log("clear action on click"); +}; From 13b5cda19b4020d2d5c5384c962e3b8a3aacc552 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Mon, 20 Apr 2026 17:35:24 +0200 Subject: [PATCH 02/18] Improved behavior and interactions --- packages/lib/src/time-input/TimeInput.tsx | 134 +++++++++++++++--- packages/lib/src/time-input/TimePicker.tsx | 16 ++- .../lib/src/time-input/TimeSpinButton.tsx | 4 + packages/lib/src/time-input/types.ts | 4 + packages/lib/src/time-input/utils.ts | 26 ++-- 5 files changed, 145 insertions(+), 39 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index a68cb92b8..3eecec773 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -78,9 +78,23 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const minuteRef = useRef<HTMLSpanElement>(null); const secondRef = useRef<HTMLSpanElement>(null); const dayPeriodRef = useRef<HTMLSpanElement>(null); - // const isControlled = useRef(value !== undefined); + const isControlled = useRef(value !== undefined); const translatedLabels = useContext(HalstackLanguageContext); + const generateEventValue = ({ + hour, + minute, + second, + dayPeriod, + }: { + hour?: number; + minute?: number; + second?: number; + dayPeriod?: number; + }) => { + return `${hour ?? hourValue}:${minute ?? minuteValue}${showSeconds ? `:${second ?? secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; + }; + useEffect(() => { const time = value || defaultValue; if (time) { @@ -94,29 +108,49 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }, [value, defaultValue]); - // useEffect(() => { - // let valueToEmit = `${hourValue}:${minuteValue}`; - // if (showSeconds) { - // valueToEmit += `:${secondValue}`; - // } - // if (timeFormat === "12") { - // valueToEmit += dayPeriod; - // } - // if (typeof onChange === "function") { - // onChange(valueToEmit); - // } - // }, [hourValue, minuteValue, secondValue, dayPeriod]); - return ( <> <DxcPopover popoverContent={ <TimePicker - onSelecthours={setHourValue} - onSelectMinutes={setMinuteValue} - onSelectSeconds={setSecondValue} + onSelecthours={(value) => { + if (!isControlled.current) { + setHourValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ hour: value })); + } + }} + onSelectMinutes={(value) => { + if (!isControlled.current) { + setMinuteValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ minute: value })); + } + }} + onSelectSeconds={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ second: value })); + } + }} + onSelectDayPeriod={(isAM) => { + if (!isControlled.current) { + setDayPeriod(isAM ? 0 : 1); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ dayPeriod: isAM ? 0 : 1 })); + } + }} timeFormat={timeFormat} showSeconds={showSeconds} + hourValue={hourValue} + minuteValue={minuteValue} + secondValue={secondValue} + dayPeriod={dayPeriod} /> } asChild @@ -132,15 +166,13 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onBlur={() => { if (typeof onBlur === "function") { onBlur({ - value: `${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}`, + value: generateEventValue({}), }); } }} onChange={() => { if (typeof onChange === "function") { - onChange( - `${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}` - ); + onChange(generateEventValue({})); } }} > @@ -168,6 +200,19 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( minuteRef.current.focus(); } }} + onChange={(value) => { + if (!isControlled.current) { + setHourValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ hour: value })); + } + }} + onNext={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} ref={hourRef} /> <ColonContainer>:</ColonContainer> @@ -186,6 +231,24 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dayPeriodRef.current.focus(); } }} + onChange={(value) => { + if (!isControlled.current) { + setMinuteValue(value); + } + onChange?.(generateEventValue({ minute: value })); + }} + onNext={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onPrevious={() => { + if (hourRef.current) { + hourRef.current.focus(); + } + }} ref={minuteRef} /> {showSeconds && ( @@ -204,6 +267,22 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dayPeriodRef.current.focus(); } }} + onChange={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + onChange?.(generateEventValue({ second: value })); + }} + onNext={() => { + if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onPrevious={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} ref={secondRef} /> </> @@ -218,6 +297,19 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} dataType="dayPeriod" interactive={!disabled && !readOnly} + onChange={(value) => { + if (!isControlled.current) { + setDayPeriod(value); + } + onChange?.(generateEventValue({ dayPeriod: value })); + }} + onPrevious={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (minuteRef.current) { + minuteRef.current.focus(); + } + }} ref={dayPeriodRef} /> )} diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index e9be702d1..c47a0f1f5 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -49,6 +49,10 @@ const TimePicker = ({ onSelectDayPeriod, timeFormat, showSeconds, + hourValue, + minuteValue, + secondValue, + dayPeriod, }: TimePickerPropsType) => { const hours = timeFormat === "12" ? 12 : 24; return ( @@ -58,8 +62,10 @@ const TimePicker = ({ {Array.from({ length: hours }, (_, index) => ( <TimePickerOption key={index} - selected={false} - onClick={() => onSelecthours(index + 1 === 24 ? 0 : index + 1)} + selected={hourValue === (index + 1 === 24 ? 0 : index + 1)} + onClick={() => { + onSelecthours(index + 1 === 24 ? 0 : index + 1); + }} > {index + 1 === 24 ? "00" : index + 1 < 10 ? `0${index + 1}` : index + 1} </TimePickerOption> @@ -69,7 +75,7 @@ const TimePicker = ({ <DxcContainer maxHeight="100%" overflow="auto"> <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> {Array.from({ length: 60 }, (_, index) => ( - <TimePickerOption key={index} selected={false} onClick={() => onSelectMinutes(index)}> + <TimePickerOption key={index} selected={minuteValue === index} onClick={() => onSelectMinutes(index)}> {index < 10 ? `0${index}` : index} </TimePickerOption> ))} @@ -79,7 +85,7 @@ const TimePicker = ({ <DxcContainer maxHeight="100%" overflow="auto"> <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> {Array.from({ length: 60 }, (_, index) => ( - <TimePickerOption key={index} selected={false} onClick={() => onSelectSeconds(index)}> + <TimePickerOption key={index} selected={secondValue === index} onClick={() => onSelectSeconds(index)}> {index < 10 ? `0${index}` : index} </TimePickerOption> ))} @@ -92,7 +98,7 @@ const TimePicker = ({ {["AM", "PM"].map((period) => ( <TimePickerOption key={period} - selected={false} + selected={dayPeriod === (period === "AM" ? 0 : 1)} onClick={() => { if (typeof onSelectDayPeriod === "function") { onSelectDayPeriod(period === "AM"); diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 44d6b205c..2d995aa19 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -47,6 +47,10 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( spanRef.current.textContent = displayValue; }, [innerValue, placeholder, maxValue, dataType]); + useEffect(() => { + setInnerValue(value); + }, [value]); + // Value used to track the raw input before it's resolved to a valid value. const rawInput = useRef<string>(""); const newDigit = useRef<string>(""); diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index c7edad0e2..ad3eff621 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -116,6 +116,10 @@ export type TimePickerPropsType = { onSelectDayPeriod?: (isAM: boolean) => void; timeFormat: "12" | "24"; showSeconds: boolean; + hourValue?: number; + minuteValue?: number; + secondValue?: number; + dayPeriod?: number; }; export default Props; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index b1c88cb59..18d8541b7 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -39,6 +39,7 @@ export const handleKeyDown = ( onPrevious?: () => void ) => { const input = event.currentTarget; + let newValue: number | undefined = innerValue; if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); rawInput.current = rawInput.current.slice(0, -1); @@ -60,7 +61,7 @@ export const handleKeyDown = ( // Number input newDigit.current = event.key; rawInput.current = (rawInput.current + newDigit.current).slice(-maxValue.toString().length); - const newValue = resolveValue(rawInput.current, maxValue, minValue); + newValue = resolveValue(rawInput.current, maxValue, minValue); // If the raw input has reached the max length or exceeds the max value with the new digit, consider it complete and move to the next field. if (checkCompletion(rawInput.current, maxValue)) { const newStringValue = newValue.toString(); @@ -75,30 +76,29 @@ export const handleKeyDown = ( } } input.textContent = rawInput.current; - setInnerValue(newValue); - if (typeof onChange === "function") { - onChange(newValue); - } } else if (event.key === "ArrowUp") { if (innerValue == null || innerValue >= maxValue) { - setInnerValue(minValue); + newValue = minValue; } else { - const newValue = resolveValue(innerValue + 1, maxValue, minValue); - setInnerValue(newValue); + newValue = resolveValue(innerValue + 1, maxValue, minValue); } } else if (event.key === "ArrowDown") { if (innerValue == null || innerValue <= minValue) { - setInnerValue(maxValue); + newValue = maxValue; } else { - const newValue = resolveValue(innerValue - 1, maxValue, minValue); - setInnerValue(newValue); + newValue = resolveValue(innerValue - 1, maxValue, minValue); } } else if (isDayPeriod && /[apAP01]/.test(event.key)) { // AM/PM input const isAM = /[aA0]/.test(event.key); - setInnerValue(isAM ? 0 : 1); + newValue = isAM ? 0 : 1; } - + setInnerValue((prevValue) => { + if (typeof onChange === "function") { + onChange(newValue); + } + return prevValue !== newValue ? newValue : prevValue; + }); if (event.key === "ArrowRight" && typeof onNext === "function") { onNext(); } else if (event.key === "ArrowLeft" && typeof onPrevious === "function") { From 3161247abb714ff7cae1592ed940f0a509b44df2 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Tue, 21 Apr 2026 11:05:37 +0200 Subject: [PATCH 03/18] Clearable and controlled behavior added --- .../lib/src/time-input/TimeInput.stories.tsx | 9 ++++ packages/lib/src/time-input/TimeInput.tsx | 29 +++++++++--- packages/lib/src/time-input/TimePicker.tsx | 16 ++++++- .../lib/src/time-input/TimeSpinButton.tsx | 45 +++++++++++++++---- packages/lib/src/time-input/types.ts | 1 + packages/lib/src/time-input/utils.ts | 21 +++------ 6 files changed, 91 insertions(+), 30 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 69b786c91..55a4769f9 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -27,6 +27,15 @@ const TimeInput = () => ( <DxcTimeInput label="Time" helperText="Helper text" /> <DxcTimeInput label="Time" helperText="Helper text" showSeconds /> <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds /> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + clearable + value="12:00" + onChange={(val) => console.log(val)} + /> </ExampleContainer> </> ); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 3eecec773..c61eef8f5 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -10,8 +10,8 @@ import TimeSpinButton from "./TimeSpinButton"; import DxcFlex from "../flex/Flex"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcPopover from "../popover/Popover"; -import { handleClearActionOnClick } from "./utils"; import TimePicker from "./TimePicker"; +import { pad } from "./utils"; const TimeInputContainer = styled.div<{ size: TimeInputPropsType["size"]; @@ -92,7 +92,10 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( second?: number; dayPeriod?: number; }) => { - return `${hour ?? hourValue}:${minute ?? minuteValue}${showSeconds ? `:${second ?? secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; + if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { + return ""; + } + return `${pad(hour ?? hourValue)}:${pad(minute ?? minuteValue)}${showSeconds ? `:${pad(second ?? secondValue)}` : ""}${timeFormat === "12" ? `${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; }; useEffect(() => { @@ -108,6 +111,18 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }, [value, defaultValue]); + const handleClearActionOnClick = () => { + if (!isControlled.current) { + setHourValue(undefined); + setMinuteValue(undefined); + setSecondValue(undefined); + setDayPeriod(undefined); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined })); + } + }; + return ( <> <DxcPopover @@ -195,6 +210,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} dataType="hour" interactive={!disabled && !readOnly} + isControlled={isControlled.current} onComplete={() => { if (minuteRef.current) { minuteRef.current.focus(); @@ -224,6 +240,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} dataType="minute" interactive={!disabled && !readOnly} + isControlled={isControlled.current} onComplete={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); @@ -262,6 +279,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} dataType="second" interactive={!disabled && !readOnly} + isControlled={isControlled.current} onComplete={() => { if (timeFormat === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); @@ -297,6 +315,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} dataType="dayPeriod" interactive={!disabled && !readOnly} + isControlled={isControlled.current} onChange={(value) => { if (!isControlled.current) { setDayPeriod(value); @@ -314,7 +333,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( /> )} </DxcFlex> - <span> + <DxcFlex> {clearable && ( <DxcActionIcon size="xsmall" @@ -331,7 +350,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( title="Select time" onClick={() => setIsOpen(true)} /> - </span> + </DxcFlex> </TimeInputField> </TimeInputContainer> </DxcPopover> @@ -339,7 +358,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( aria-label={ariaLabel} type="hidden" name={name} - value={`${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : ""}`} + value={`${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? `${dayPeriod === 0 ? "AM" : "PM"}` : ""}`} /> </> ); diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index c47a0f1f5..737dc8375 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -63,6 +63,7 @@ const TimePicker = ({ <TimePickerOption key={index} selected={hourValue === (index + 1 === 24 ? 0 : index + 1)} + autoFocus={hourValue === (index + 1 === 24 ? 0 : index + 1)} onClick={() => { onSelecthours(index + 1 === 24 ? 0 : index + 1); }} @@ -75,7 +76,12 @@ const TimePicker = ({ <DxcContainer maxHeight="100%" overflow="auto"> <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> {Array.from({ length: 60 }, (_, index) => ( - <TimePickerOption key={index} selected={minuteValue === index} onClick={() => onSelectMinutes(index)}> + <TimePickerOption + key={index} + selected={minuteValue === index} + autoFocus={minuteValue === index} + onClick={() => onSelectMinutes(index)} + > {index < 10 ? `0${index}` : index} </TimePickerOption> ))} @@ -85,7 +91,12 @@ const TimePicker = ({ <DxcContainer maxHeight="100%" overflow="auto"> <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> {Array.from({ length: 60 }, (_, index) => ( - <TimePickerOption key={index} selected={secondValue === index} onClick={() => onSelectSeconds(index)}> + <TimePickerOption + key={index} + selected={secondValue === index} + autoFocus={secondValue === index} + onClick={() => onSelectSeconds(index)} + > {index < 10 ? `0${index}` : index} </TimePickerOption> ))} @@ -99,6 +110,7 @@ const TimePicker = ({ <TimePickerOption key={period} selected={dayPeriod === (period === "AM" ? 0 : 1)} + autoFocus={dayPeriod === (period === "AM" ? 0 : 1)} onClick={() => { if (typeof onSelectDayPeriod === "function") { onSelectDayPeriod(period === "AM"); diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 2d995aa19..7dd21a81b 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -12,9 +12,37 @@ const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean }>` } `; +const generateDisplayValue = ( + dataType: "hour" | "minute" | "second" | "dayPeriod" | undefined, + value: number | undefined, + placeholder: string, + maxValue: number +) => { + let displayValue; + if (dataType === "dayPeriod") { + displayValue = value === 0 ? "AM" : value === 1 ? "PM" : placeholder; + } else { + displayValue = value != null ? value.toString().padStart(maxValue.toString().length, "0") : placeholder; + } + return displayValue; +}; + const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( ( - { value, minValue, maxValue, inputId, tabIndex, dataType, interactive, onChange, onComplete, onNext, onPrevious }, + { + value, + minValue, + maxValue, + inputId, + tabIndex, + dataType, + interactive, + isControlled, + onChange, + onComplete, + onNext, + onPrevious, + }, ref ) => { const [innerValue, setInnerValue] = useState<number | undefined>(value); @@ -37,18 +65,18 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( useEffect(() => { if (!spanRef.current) return; - let displayValue; - if (dataType === "dayPeriod") { - displayValue = innerValue === 0 ? "AM" : innerValue === 1 ? "PM" : placeholder; + if (!isControlled) { + spanRef.current.textContent = generateDisplayValue(dataType, innerValue, placeholder, maxValue); } else { - displayValue = - innerValue != null ? innerValue.toString().padStart(maxValue.toString().length, "0") : placeholder; + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); } - spanRef.current.textContent = displayValue; - }, [innerValue, placeholder, maxValue, dataType]); + }, [innerValue, placeholder, maxValue, dataType, isControlled]); useEffect(() => { setInnerValue(value); + if (spanRef.current) { + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); + } }, [value]); // Value used to track the raw input before it's resolved to a valid value. @@ -88,7 +116,6 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( spanRef, setInnerValue, innerValue, - placeholder, maxValue, minValue, dataType === "dayPeriod", diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index ad3eff621..b9d885453 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -103,6 +103,7 @@ export type TimeSpinButtonPropsType = { tabIndex: number; dataType?: "hour" | "minute" | "second" | "dayPeriod"; interactive: boolean; + isControlled: boolean; onComplete?: () => void; onChange?: (value: number | undefined) => void; onNext?: () => void; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 18d8541b7..d8f19706f 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -1,3 +1,5 @@ +export const pad = (num?: number) => (num !== undefined && num < 10 ? `0${num}` : `${num}`); + const resolveValue = (value: string | number, maxValue: number, minValue: number) => { const input = typeof value === "string" ? parseInt(value, 10) : value; if (input > maxValue) { @@ -29,7 +31,6 @@ export const handleKeyDown = ( spanRef: React.MutableRefObject<HTMLSpanElement | null>, setInnerValue: React.Dispatch<React.SetStateAction<number | undefined>>, innerValue: number | undefined, - placeholder: string, maxValue: number, minValue: number, isDayPeriod?: boolean, @@ -45,14 +46,10 @@ export const handleKeyDown = ( rawInput.current = rawInput.current.slice(0, -1); if (!spanRef.current) return; if (rawInput.current === "") { - setInnerValue(undefined); - spanRef.current.textContent = placeholder; + newValue = undefined; } else { - const numericValue = parseInt(rawInput.current, 10); - setInnerValue(numericValue); - spanRef.current.textContent = rawInput.current; + newValue = parseInt(rawInput.current, 10); } - return; } if (!["Tab", "Enter"].includes(event.key)) event.preventDefault(); @@ -94,18 +91,14 @@ export const handleKeyDown = ( newValue = isAM ? 0 : 1; } setInnerValue((prevValue) => { - if (typeof onChange === "function") { - onChange(newValue); - } return prevValue !== newValue ? newValue : prevValue; }); + if (typeof onChange === "function") { + onChange(newValue); + } if (event.key === "ArrowRight" && typeof onNext === "function") { onNext(); } else if (event.key === "ArrowLeft" && typeof onPrevious === "function") { onPrevious(); } }; - -export const handleClearActionOnClick = () => { - console.log("clear action on click"); -}; From 0fbda9e9ca764674020c0d3b3dfdceb598fc3765 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Tue, 21 Apr 2026 14:24:33 +0200 Subject: [PATCH 04/18] Added keyboard dupport to TimePicker --- .../lib/src/time-input/TimeInput.stories.tsx | 2 +- packages/lib/src/time-input/TimeInput.tsx | 146 +++++++++--------- packages/lib/src/time-input/TimePicker.tsx | 141 +++++++++++++++-- packages/lib/src/time-input/types.ts | 4 +- 4 files changed, 207 insertions(+), 86 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 55a4769f9..875d31ca1 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -33,7 +33,7 @@ const TimeInput = () => ( helperText="Helper text" timeFormat="24" clearable - value="12:00" + value="18:30" onChange={(val) => console.log(val)} /> </ExampleContainer> diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index c61eef8f5..74daf62ff 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -125,80 +125,82 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( return ( <> - <DxcPopover - popoverContent={ - <TimePicker - onSelecthours={(value) => { - if (!isControlled.current) { - setHourValue(value); - } - if (typeof onChange === "function") { - onChange(generateEventValue({ hour: value })); - } - }} - onSelectMinutes={(value) => { - if (!isControlled.current) { - setMinuteValue(value); - } - if (typeof onChange === "function") { - onChange(generateEventValue({ minute: value })); - } - }} - onSelectSeconds={(value) => { - if (!isControlled.current) { - setSecondValue(value); - } - if (typeof onChange === "function") { - onChange(generateEventValue({ second: value })); - } - }} - onSelectDayPeriod={(isAM) => { - if (!isControlled.current) { - setDayPeriod(isAM ? 0 : 1); - } - if (typeof onChange === "function") { - onChange(generateEventValue({ dayPeriod: isAM ? 0 : 1 })); - } - }} - timeFormat={timeFormat} - showSeconds={showSeconds} - hourValue={hourValue} - minuteValue={minuteValue} - secondValue={secondValue} - dayPeriod={dayPeriod} - /> - } - asChild - isOpen={isOpen} - onClose={() => { - setIsOpen(false); + <TimeInputContainer + size={size} + ref={ref} + onBlur={() => { + if (typeof onBlur === "function") { + onBlur({ + value: generateEventValue({}), + }); + } + }} + onChange={() => { + if (typeof onChange === "function") { + onChange(generateEventValue({})); + } }} - align="end" > - <TimeInputContainer - size={size} - ref={ref} - onBlur={() => { - if (typeof onBlur === "function") { - onBlur({ - value: generateEventValue({}), - }); - } - }} - onChange={() => { - if (typeof onChange === "function") { - onChange(generateEventValue({})); - } + <Label disabled={disabled} hasMargin={!helperText} htmlFor={inputId}> + {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} + </Label> + {helperText && ( + <HelperText disabled={disabled} hasMargin> + {helperText} + </HelperText> + )} + <DxcPopover + popoverContent={ + <TimePicker + onSelecthours={(value) => { + if (!isControlled.current) { + setHourValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ hour: value })); + } + }} + onSelectMinutes={(value) => { + if (!isControlled.current) { + setMinuteValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ minute: value })); + } + }} + onSelectSeconds={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ second: value })); + } + }} + onSelectDayPeriod={(value: number) => { + if (!isControlled.current) { + setDayPeriod(value ? 1 : 0); + } + if (typeof onChange === "function") { + onChange(generateEventValue({ dayPeriod: value ? 1 : 0 })); + } + }} + timeFormat={timeFormat} + showSeconds={showSeconds} + hourValue={hourValue} + minuteValue={minuteValue} + secondValue={secondValue} + dayPeriod={dayPeriod} + id={inputId} + tabIndex={tabIndex} + /> + } + asChild + isOpen={isOpen} + onClose={() => { + setIsOpen(false); }} + align="end" > - <Label disabled={disabled} hasMargin={!helperText} htmlFor={inputId}> - {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} - </Label> - {helperText && ( - <HelperText disabled={disabled} hasMargin> - {helperText} - </HelperText> - )} <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center"> <DxcFlex gap="var(--spacing-gap-xxs)" alignItems="center"> @@ -352,8 +354,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( /> </DxcFlex> </TimeInputField> - </TimeInputContainer> - </DxcPopover> + </DxcPopover> + </TimeInputContainer> <input aria-label={ariaLabel} type="hidden" diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 737dc8375..9d94c7a06 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -2,6 +2,7 @@ import styled from "@emotion/styled"; import { TimePickerPropsType } from "./types"; import DxcContainer from "../container/Container"; import DxcFlex from "../flex/Flex"; +import { useEffect, useState } from "react"; const TimePickerContainer = styled.div` display: flex; @@ -42,6 +43,47 @@ const TimePickerOption = styled.button<{ } `; +const handleColumnKeyDown = ( + event: React.KeyboardEvent, + column: string, + focusedValue: number, + totalValues: number, + setValueToFocus: React.Dispatch<React.SetStateAction<number>>, + onSelect?: (value: number) => void +) => { + // ignore tab key to allow normal tab behavior, and prevent default for other keys to manage focus manually + if (!["Tab"].includes(event.key)) event.preventDefault(); + if (event.key === "ArrowDown") { + if (column === "hour" && focusedValue === 23) { + setValueToFocus(0); + } else if (column === "hour") { + const newValue = focusedValue + 1 > totalValues ? 1 : focusedValue + 1; + setValueToFocus((prev) => (prev === undefined ? 1 : newValue)); + } else if (focusedValue === totalValues - 1) { + setValueToFocus(0); + } else { + const newValue = focusedValue + 1 > totalValues - 1 ? 0 : focusedValue + 1; + setValueToFocus(newValue); + } + } else if (event.key === "ArrowUp") { + if (column === "hour" && focusedValue === 0) { + setValueToFocus(23); + } else if (column === "hour") { + const newValue = focusedValue - 1 < 0 ? totalValues - 1 : focusedValue - 1; + setValueToFocus((prev) => (prev === undefined ? totalValues - 1 : newValue)); + } else if (focusedValue === 0) { + setValueToFocus(totalValues - 1); + } else { + const newValue = focusedValue - 1 < 0 ? totalValues - 1 : focusedValue - 1; + setValueToFocus(newValue); + } + } else if (["Enter", " "].includes(event.key)) { + if (onSelect) { + onSelect(focusedValue); + } + } +}; + const TimePicker = ({ onSelecthours, onSelectMinutes, @@ -53,20 +95,65 @@ const TimePicker = ({ minuteValue, secondValue, dayPeriod, + id, + tabIndex = 0, }: TimePickerPropsType) => { - const hours = timeFormat === "12" ? 12 : 24; + const [hourToFocus, setHourToFocus] = useState(hourValue || 1); + const [minuteToFocus, setMinuteToFocus] = useState(minuteValue || 0); + const [secondToFocus, setSecondToFocus] = useState(secondValue || 0); + const [dayPeriodToFocus, setDayPeriodToFocus] = useState(dayPeriod || 0); + const totalHours = timeFormat === "12" ? 12 : 24; + + useEffect(() => { + if (hourToFocus !== undefined) { + document.getElementById(`${id}-hour-${hourToFocus}`)?.focus(); + } + }, [hourToFocus]); + useEffect(() => { + if (minuteToFocus !== undefined) { + document.getElementById(`${id}-minute-${minuteToFocus}`)?.focus(); + } + }, [minuteToFocus]); + useEffect(() => { + if (secondToFocus !== undefined) { + document.getElementById(`${id}-second-${secondToFocus}`)?.focus(); + } + }, [secondToFocus]); + useEffect(() => { + if (dayPeriodToFocus !== undefined) { + document.getElementById(`${id}-dayPeriod-${dayPeriodToFocus}`)?.focus(); + } + }, [dayPeriodToFocus]); + + // Function that returns the hour value based on the index and the format. + const returnHourBasedOnIndex = (index: number) => (index + 1 === 24 ? 0 : index + 1); + return ( <TimePickerContainer> <DxcContainer maxHeight="100%" overflow="auto"> <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> - {Array.from({ length: hours }, (_, index) => ( + {Array.from({ length: totalHours }, (_, index) => ( <TimePickerOption - key={index} - selected={hourValue === (index + 1 === 24 ? 0 : index + 1)} - autoFocus={hourValue === (index + 1 === 24 ? 0 : index + 1)} + key={`hour-${returnHourBasedOnIndex(index)}`} + id={`${id}-hour-${returnHourBasedOnIndex(index)}`} + selected={hourValue === returnHourBasedOnIndex(index)} + aria-selected={hourValue === returnHourBasedOnIndex(index)} + autoFocus={hourToFocus === returnHourBasedOnIndex(index)} + tabIndex={hourToFocus === returnHourBasedOnIndex(index) ? tabIndex || 0 : -1} onClick={() => { - onSelecthours(index + 1 === 24 ? 0 : index + 1); + onSelecthours(returnHourBasedOnIndex(index)); + setHourToFocus(returnHourBasedOnIndex(index)); }} + onKeyDown={(event) => + handleColumnKeyDown( + event, + "hour", + returnHourBasedOnIndex(index), + totalHours, + setHourToFocus, + onSelecthours + ) + } > {index + 1 === 24 ? "00" : index + 1 < 10 ? `0${index + 1}` : index + 1} </TimePickerOption> @@ -78,9 +165,16 @@ const TimePicker = ({ {Array.from({ length: 60 }, (_, index) => ( <TimePickerOption key={index} + id={`${id}-minute-${index}`} selected={minuteValue === index} - autoFocus={minuteValue === index} - onClick={() => onSelectMinutes(index)} + aria-selected={minuteValue === index} + autoFocus={minuteToFocus === index} + tabIndex={minuteToFocus === index ? tabIndex || 0 : -1} + onClick={() => { + onSelectMinutes(index); + setMinuteToFocus(index); + }} + onKeyDown={(event) => handleColumnKeyDown(event, "minute", index, 60, setMinuteToFocus, onSelectMinutes)} > {index < 10 ? `0${index}` : index} </TimePickerOption> @@ -93,9 +187,18 @@ const TimePicker = ({ {Array.from({ length: 60 }, (_, index) => ( <TimePickerOption key={index} + id={`${id}-second-${index}`} selected={secondValue === index} - autoFocus={secondValue === index} - onClick={() => onSelectSeconds(index)} + aria-selected={secondValue === index} + autoFocus={secondToFocus === index} + tabIndex={secondToFocus === index ? tabIndex || 0 : -1} + onClick={() => { + onSelectSeconds(index); + setSecondToFocus(index); + }} + onKeyDown={(event) => + handleColumnKeyDown(event, "second", index, 60, setSecondToFocus, onSelectSeconds) + } > {index < 10 ? `0${index}` : index} </TimePickerOption> @@ -109,13 +212,27 @@ const TimePicker = ({ {["AM", "PM"].map((period) => ( <TimePickerOption key={period} + id={`${id}-dayPeriod-${period === "AM" ? 0 : 1}`} selected={dayPeriod === (period === "AM" ? 0 : 1)} - autoFocus={dayPeriod === (period === "AM" ? 0 : 1)} + aria-selected={dayPeriod === (period === "AM" ? 0 : 1)} + autoFocus={dayPeriodToFocus === (period === "AM" ? 0 : 1)} + tabIndex={dayPeriodToFocus === (period === "AM" ? 0 : 1) ? tabIndex || 0 : -1} onClick={() => { if (typeof onSelectDayPeriod === "function") { - onSelectDayPeriod(period === "AM"); + onSelectDayPeriod(period === "AM" ? 0 : 1); + setDayPeriodToFocus(period === "AM" ? 0 : 1); } }} + onKeyDown={(event) => + handleColumnKeyDown( + event, + "dayPeriod", + period === "AM" ? 0 : 1, + 2, + setDayPeriodToFocus, + onSelectDayPeriod + ) + } > {period} </TimePickerOption> diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index b9d885453..f739f04af 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -114,13 +114,15 @@ export type TimePickerPropsType = { onSelecthours: (hours: number) => void; onSelectMinutes: (minutes: number) => void; onSelectSeconds: (seconds: number) => void; - onSelectDayPeriod?: (isAM: boolean) => void; + onSelectDayPeriod?: (isPM: number) => void; timeFormat: "12" | "24"; showSeconds: boolean; hourValue?: number; minuteValue?: number; secondValue?: number; dayPeriod?: number; + id?: string; + tabIndex?: number; }; export default Props; From 51d06eb789c20aba2e6209cb782e967669fa8f19 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Wed, 22 Apr 2026 12:24:04 +0200 Subject: [PATCH 05/18] Chromatic tests added --- .../lib/src/time-input/TimeInput.stories.tsx | 278 ++++++++++++++++-- packages/lib/src/time-input/TimeInput.tsx | 25 +- packages/lib/src/time-input/TimePicker.tsx | 6 +- packages/lib/src/time-input/types.ts | 4 +- 4 files changed, 277 insertions(+), 36 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 875d31ca1..c82a77e4d 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -1,9 +1,13 @@ import DxcTimeInput from "./TimeInput"; +import TimePicker from "./TimePicker"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; import { Meta, StoryObj } from "@storybook/react-vite"; import preview from "../../.storybook/preview"; import disabledRules from "../../test/accessibility/rules/common/disabledRules"; +import { useState } from "react"; +import DxcContainer from "../container/Container"; +import { userEvent, within } from "storybook/internal/test"; export default { title: "Time Input", @@ -20,28 +24,264 @@ export default { }, } satisfies Meta<typeof DxcTimeInput>; -const TimeInput = () => ( - <> - <Title title="Default" theme="light" level={2} /> - <ExampleContainer> - <DxcTimeInput label="Time" helperText="Helper text" /> - <DxcTimeInput label="Time" helperText="Helper text" showSeconds /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds /> - <DxcTimeInput - label="Time" - helperText="Helper text" - timeFormat="24" - clearable - value="18:30" - onChange={(val) => console.log(val)} - /> - </ExampleContainer> - </> -); +const TimeInput = () => { + const [continentalValue, setContinentalValue] = useState<string>("18:30:20"); + const [value] = useState<string>("6:30:20 PM"); + return ( + <> + <ExampleContainer> + <Title title="Default" theme="light" level={2} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> + <DxcContainer width="175px"> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + clearable + value={continentalValue} + onChange={(val) => { + console.log(`Value changed: ${val}`); + setContinentalValue(val); + }} + size="fillParent" + /> + </DxcContainer> + <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-hover"}> + <Title title="Hover" theme="light" level={2} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> + <DxcContainer width="175px"> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + clearable + value={continentalValue} + onChange={(val) => { + console.log(`Value changed: ${val}`); + setContinentalValue(val); + }} + size="fillParent" + /> + </DxcContainer> + <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-focus"}> + <Title title="Focus" theme="light" level={2} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> + <DxcContainer width="175px"> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + clearable + value={continentalValue} + onChange={(val) => { + console.log(`Value changed: ${val}`); + setContinentalValue(val); + }} + size="fillParent" + /> + </DxcContainer> + <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-active"}> + <Title title="Active" theme="light" level={2} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> + <DxcContainer width="175px"> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + clearable + value={continentalValue} + onChange={(val) => { + console.log(`Value changed: ${val}`); + setContinentalValue(val); + }} + size="fillParent" + /> + </DxcContainer> + <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + </ExampleContainer> + </> + ); +}; + +const TimePickerExamples = () => { + return ( + <> + <ExampleContainer expanded> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue="6:30:20 PM" timeFormat="12" /> + </ExampleContainer> + <ExampleContainer> + <Title title="Time Picker 24h format" theme="light" level={3} /> + <DxcContainer width="250px"> + <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="24" id="testId" tabIndex={0} /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="24" + id="testId" + tabIndex={0} + hourValue={15} + minuteValue={30} + /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="24" + id="testId" + tabIndex={0} + showSeconds + hourValue={15} + minuteValue={30} + secondValue={10} + /> + </DxcContainer> + </ExampleContainer> + <ExampleContainer> + <Title title="Time Picker 12h format" theme="light" level={3} /> + <DxcContainer width="250px"> + <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="12" id="testId" tabIndex={0} /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + hourValue={3} + minuteValue={30} + dayPeriod={1} + /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + showSeconds + hourValue={3} + minuteValue={30} + secondValue={10} + dayPeriod={1} + /> + </DxcContainer> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-hover"}> + <Title title="hover" theme="light" level={3} /> + <DxcContainer width="250px"> + <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="12" id="testId" tabIndex={0} /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + hourValue={3} + minuteValue={30} + dayPeriod={1} + /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + showSeconds + hourValue={3} + minuteValue={30} + secondValue={10} + dayPeriod={1} + /> + </DxcContainer> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-focus"}> + <Title title="focus" theme="light" level={3} /> + <DxcContainer width="250px"> + <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="12" id="testId" tabIndex={0} /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + hourValue={3} + minuteValue={30} + dayPeriod={1} + /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + showSeconds + hourValue={3} + minuteValue={30} + secondValue={10} + dayPeriod={1} + /> + </DxcContainer> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-active"}> + <Title title="active" theme="light" level={3} /> + <DxcContainer width="250px"> + <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="12" id="testId" tabIndex={0} /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + hourValue={3} + minuteValue={30} + dayPeriod={1} + /> + <TimePicker + onSelectMinutes={() => {}} + onSelecthours={() => {}} + timeFormat="12" + id="testId" + tabIndex={0} + showSeconds + hourValue={3} + minuteValue={30} + secondValue={10} + dayPeriod={1} + /> + </DxcContainer> + </ExampleContainer> + </> + ); +}; type Story = StoryObj<typeof DxcTimeInput>; export const Chromatic: Story = { render: TimeInput, }; + +export const TimePickerChromatic: Story = { + render: TimePickerExamples, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dateBtn = (await canvas.findAllByRole("button"))[0]; + if (dateBtn != null) { + await userEvent.click(dateBtn); + } + }, +}; diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 74daf62ff..4ce3a5b7f 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -95,18 +95,22 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { return ""; } - return `${pad(hour ?? hourValue)}:${pad(minute ?? minuteValue)}${showSeconds ? `:${pad(second ?? secondValue)}` : ""}${timeFormat === "12" ? `${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; + return `${pad(hour ?? hourValue)}:${pad(minute ?? minuteValue)}${showSeconds ? `:${pad(second ?? secondValue)}` : ""}${timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; }; useEffect(() => { const time = value || defaultValue; if (time) { - const [hour, minute, second] = time.split(":").map(Number); - setHourValue(hour); - setMinuteValue(minute); - setSecondValue(second); - if (timeFormat === "12") { - setDayPeriod(hour && hour >= 12 ? 1 : 0); + const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; + if (numberPart) { + const [hour, minute, second] = numberPart.split(":").map(Number); + setHourValue(hour); + setMinuteValue(minute); + setSecondValue(second); + } + if (timeFormat === "12" && time.includes(" ")) { + const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : 1; + setDayPeriod(dayPeriodValue); } } }, [value, defaultValue]); @@ -356,12 +360,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </TimeInputField> </DxcPopover> </TimeInputContainer> - <input - aria-label={ariaLabel} - type="hidden" - name={name} - value={`${hourValue}:${minuteValue}${showSeconds ? `:${secondValue}` : ""}${timeFormat === "12" ? `${dayPeriod === 0 ? "AM" : "PM"}` : ""}`} - /> + <input aria-label={ariaLabel} type="hidden" name={name} value={generateEventValue({})} /> </> ); } diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 9d94c7a06..80bd68d9a 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -193,8 +193,10 @@ const TimePicker = ({ autoFocus={secondToFocus === index} tabIndex={secondToFocus === index ? tabIndex || 0 : -1} onClick={() => { - onSelectSeconds(index); - setSecondToFocus(index); + if (typeof onSelectSeconds === "function") { + onSelectSeconds(index); + setSecondToFocus(index); + } }} onKeyDown={(event) => handleColumnKeyDown(event, "second", index, 60, setSecondToFocus, onSelectSeconds) diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index f739f04af..ee1a8c670 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -113,10 +113,10 @@ export type TimeSpinButtonPropsType = { export type TimePickerPropsType = { onSelecthours: (hours: number) => void; onSelectMinutes: (minutes: number) => void; - onSelectSeconds: (seconds: number) => void; + onSelectSeconds?: (seconds: number) => void; onSelectDayPeriod?: (isPM: number) => void; timeFormat: "12" | "24"; - showSeconds: boolean; + showSeconds?: boolean; hourValue?: number; minuteValue?: number; secondValue?: number; From 5d4171b16a55a383e680a64cee14e84475b6cbec Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Wed, 22 Apr 2026 17:23:25 +0200 Subject: [PATCH 06/18] Fix bugs related to events and added tests --- .../lib/src/time-input/TimeInput.stories.tsx | 24 +++- .../lib/src/time-input/TimeInput.test.tsx | 82 +++++++++++ packages/lib/src/time-input/TimeInput.tsx | 127 +++++++++++++++--- 3 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 packages/lib/src/time-input/TimeInput.test.tsx diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index c82a77e4d..6e5b077b4 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -26,12 +26,24 @@ export default { const TimeInput = () => { const [continentalValue, setContinentalValue] = useState<string>("18:30:20"); - const [value] = useState<string>("6:30:20 PM"); + const [value] = useState<string>("6:30:20 AM"); return ( <> <ExampleContainer> <Title title="Default" theme="light" level={2} /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> + <DxcTimeInput + label="Time" + helperText="Helper text" + value={value} + size="small" + onChange={(val) => { + console.log(`Value changed: ${val}`); + }} + onBlur={(val) => { + console.log(`Value blurred: ${val.value}`); + }} + clearable + /> <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> @@ -50,6 +62,14 @@ const TimeInput = () => { /> </DxcContainer> <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds readOnly /> + <DxcTimeInput + label="Time" + helperText="Helper text" + defaultValue={value} + showSeconds + error="This is not a valid time" + /> </ExampleContainer> <ExampleContainer pseudoState={"pseudo-hover"}> <Title title="Hover" theme="light" level={2} /> diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx new file mode 100644 index 000000000..36c3e1335 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -0,0 +1,82 @@ +import { render } from "@testing-library/react"; +import DxcTimeInput from "./TimeInput"; +import MockDOMRect from "../../test/mocks/domRectMock"; +import userEvent from "@testing-library/user-event"; + +// Mocking DOMRect for Radix Primitive Popover +global.DOMRect = MockDOMRect; +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +beforeEach(() => jest.clearAllMocks()); + +beforeEach(() => jest.clearAllMocks()); + +describe("DxcTimeInput rendering", () => { + it("renders label", () => { + const { getByText } = render(<DxcTimeInput label="Time input" helperText="Pick a time" />); + expect(getByText("Time input")).toBeTruthy(); + expect(getByText("Pick a time")).toBeTruthy(); + }); + + it("renders hour, minute spinbuttons by default", () => { + const { getAllByRole } = render(<DxcTimeInput label="Time input" timeFormat="24" />); + const spinbuttons = getAllByRole("spinbutton"); + expect(spinbuttons).toHaveLength(2); // hour + minute + }); + + it("renders seconds spinbutton when showSeconds is true", () => { + const { getAllByRole } = render(<DxcTimeInput label="Time input" timeFormat="24" showSeconds />); + const spinbuttons = getAllByRole("spinbutton"); + expect(spinbuttons).toHaveLength(3); // hour + minute + second + }); + + it("renders AM/PM spinbutton in 12h format", () => { + const { getAllByRole } = render(<DxcTimeInput label="Time input" timeFormat="12" />); + const spinbuttons = getAllByRole("spinbutton"); + expect(spinbuttons).toHaveLength(3); // hour + minute + dayPeriod + }); + + it("renders all spinbuttons in 12h format with seconds", () => { + const { getAllByRole } = render(<DxcTimeInput label="Time input" timeFormat="12" showSeconds />); + const spinbuttons = getAllByRole("spinbutton"); + expect(spinbuttons).toHaveLength(4); // hour + minute + second + dayPeriod + }); + + it("renders clear button when clearable is true", () => { + const mockOnChange = jest.fn(); + const { getAllByRole } = render(<DxcTimeInput clearable value="05:05 AM" onChange={mockOnChange} />); + const buttons = getAllByRole("button"); + expect(buttons).toHaveLength(2); + if (buttons[0]) userEvent.click(buttons[0]); + expect(mockOnChange).toHaveBeenCalledWith(""); + }); + + it("renders time picker and values are selected", () => { + const mockOnChange = jest.fn(); + const { getByRole, getAllByRole } = render(<DxcTimeInput value="05:30 AM" onChange={mockOnChange} />); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + const hourButton = getAllByRole("button", { name: "05" }).find((hourButton) => hourButton.id.includes("hour")); + const minuteButton = getAllByRole("button", { name: "30" }).find((minuteButton) => + minuteButton.id.includes("minute") + ); + const amButton = getByRole("button", { name: "AM" }); + expect(hourButton?.getAttribute("aria-selected")).toBe("true"); + expect(minuteButton?.getAttribute("aria-selected")).toBe("true"); + expect(amButton?.getAttribute("aria-selected")).toBe("true"); + + const newHourButton = getAllByRole("button", { name: "10" }).find((hourButton) => hourButton.id.includes("hour")); + if (newHourButton) userEvent.click(newHourButton); + expect(mockOnChange).toHaveBeenCalledWith("10:30 AM"); + }); + + // it("renders error message", () => { + // render(<DxcTimeInput error="Invalid time" />); + // expect(screen.getByText("Invalid time")).toBeInTheDocument(); + // }); +}); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 4ce3a5b7f..cef9af198 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -72,7 +72,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const [hourValue, setHourValue] = useState<number | undefined>(undefined); const [minuteValue, setMinuteValue] = useState<number | undefined>(undefined); const [secondValue, setSecondValue] = useState<number | undefined>(undefined); - const [dayPeriod, setDayPeriod] = useState<number | undefined>(undefined); + const [dayPeriodValue, setDayPeriodValue] = useState<number | undefined>(undefined); const [isOpen, setIsOpen] = useState(false); const hourRef = useRef<HTMLSpanElement>(null); const minuteRef = useRef<HTMLSpanElement>(null); @@ -91,15 +91,17 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( minute?: number; second?: number; dayPeriod?: number; - }) => { + } = {}) => { if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { return ""; } - return `${pad(hour ?? hourValue)}:${pad(minute ?? minuteValue)}${showSeconds ? `:${pad(second ?? secondValue)}` : ""}${timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : dayPeriod && dayPeriod === 0 ? "AM" : "PM"}` : ""}`; + return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ + timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : "" + }`; }; useEffect(() => { - const time = value || defaultValue; + const time = value || defaultValue || undefined; if (time) { const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; if (numberPart) { @@ -110,7 +112,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } if (timeFormat === "12" && time.includes(" ")) { const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : 1; - setDayPeriod(dayPeriodValue); + setDayPeriodValue(dayPeriodValue); } } }, [value, defaultValue]); @@ -120,9 +122,13 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setHourValue(undefined); setMinuteValue(undefined); setSecondValue(undefined); - setDayPeriod(undefined); + setDayPeriodValue(undefined); } if (typeof onChange === "function") { + console.log( + "clear button clicked, value to be emitted: " + + generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined }) + ); onChange(generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined })); } }; @@ -135,13 +141,25 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onBlur={() => { if (typeof onBlur === "function") { onBlur({ - value: generateEventValue({}), + value: generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: secondValue, + dayPeriod: dayPeriodValue, + }), }); } }} onChange={() => { if (typeof onChange === "function") { - onChange(generateEventValue({})); + onChange( + generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: secondValue, + dayPeriod: dayPeriodValue, + }) + ); } }} > @@ -161,7 +179,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setHourValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue({ hour: value })); + onChange( + generateEventValue({ + hour: value, + minute: minuteValue, + second: secondValue, + dayPeriod: dayPeriodValue, + }) + ); } }} onSelectMinutes={(value) => { @@ -169,7 +194,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setMinuteValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue({ minute: value })); + onChange( + generateEventValue({ + hour: hourValue, + minute: value, + second: secondValue, + dayPeriod: dayPeriodValue, + }) + ); } }} onSelectSeconds={(value) => { @@ -177,15 +209,30 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setSecondValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue({ second: value })); + onChange( + generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: value, + dayPeriod: dayPeriodValue, + }) + ); } }} onSelectDayPeriod={(value: number) => { + console.log("selected day period: " + value); if (!isControlled.current) { - setDayPeriod(value ? 1 : 0); + setDayPeriodValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue({ dayPeriod: value ? 1 : 0 })); + onChange( + generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: secondValue, + dayPeriod: value, + }) + ); } }} timeFormat={timeFormat} @@ -193,7 +240,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( hourValue={hourValue} minuteValue={minuteValue} secondValue={secondValue} - dayPeriod={dayPeriod} + dayPeriod={dayPeriodValue} id={inputId} tabIndex={tabIndex} /> @@ -227,7 +274,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setHourValue(value); } if (typeof onChange === "function") { - onChange(generateEventValue({ hour: value })); + onChange( + generateEventValue({ + hour: value, + minute: minuteValue, + second: secondValue, + dayPeriod: dayPeriodValue, + }) + ); } }} onNext={() => { @@ -258,7 +312,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( if (!isControlled.current) { setMinuteValue(value); } - onChange?.(generateEventValue({ minute: value })); + onChange?.( + generateEventValue({ + hour: hourValue, + minute: value, + second: secondValue, + dayPeriod: dayPeriodValue, + }) + ); }} onNext={() => { if (showSeconds && secondRef.current) { @@ -295,7 +356,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( if (!isControlled.current) { setSecondValue(value); } - onChange?.(generateEventValue({ second: value })); + onChange?.( + generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: value, + dayPeriod: dayPeriodValue, + }) + ); }} onNext={() => { if (timeFormat === "12" && dayPeriodRef.current) { @@ -314,7 +382,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </DxcFlex> {timeFormat === "12" && ( <TimeSpinButton - value={dayPeriod} + value={dayPeriodValue} minValue={0} maxValue={1} inputId={inputId} @@ -324,9 +392,16 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( isControlled={isControlled.current} onChange={(value) => { if (!isControlled.current) { - setDayPeriod(value); + setDayPeriodValue(value); } - onChange?.(generateEventValue({ dayPeriod: value })); + onChange?.( + generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: secondValue, + dayPeriod: value, + }) + ); }} onPrevious={() => { if (showSeconds && secondRef.current) { @@ -360,7 +435,17 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </TimeInputField> </DxcPopover> </TimeInputContainer> - <input aria-label={ariaLabel} type="hidden" name={name} value={generateEventValue({})} /> + <input + aria-label={ariaLabel} + type="hidden" + name={name} + value={generateEventValue({ + hour: hourValue, + minute: minuteValue, + second: secondValue, + dayPeriod: dayPeriodValue, + })} + /> </> ); } From 77996741f745cdc8c80bb68246d5ad8a77c369e5 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 23 Apr 2026 10:00:23 +0200 Subject: [PATCH 07/18] Improved readOnly, error and disabled behavior --- .../lib/src/time-input/TimeInput.stories.tsx | 11 ++++++-- packages/lib/src/time-input/TimeInput.tsx | 21 ++++++++------ packages/lib/src/time-input/TimePicker.tsx | 22 +++++++-------- .../lib/src/time-input/TimeSpinButton.tsx | 28 +++++++++++-------- packages/lib/src/time-input/types.ts | 3 +- packages/lib/src/time-input/utils.ts | 2 ++ 6 files changed, 53 insertions(+), 34 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 6e5b077b4..78219b4e6 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -49,7 +49,7 @@ const TimeInput = () => { <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> <DxcContainer width="175px"> <DxcTimeInput - label="Time" + label="Time Input fill parent" helperText="Helper text" timeFormat="24" clearable @@ -62,6 +62,7 @@ const TimeInput = () => { /> </DxcContainer> <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds disabled /> <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds readOnly /> <DxcTimeInput label="Time" @@ -145,7 +146,13 @@ const TimePickerExamples = () => { return ( <> <ExampleContainer expanded> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue="6:30:20 PM" timeFormat="12" /> + <DxcTimeInput + label="Time" + helperText="Helper text" + defaultValue="6:30:20 PM" + timeFormat="12" + onBlur={() => console.log("blur")} + /> </ExampleContainer> <ExampleContainer> <Title title="Time Picker 24h format" theme="light" level={3} /> diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index cef9af198..27813315a 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -12,6 +12,7 @@ import DxcActionIcon from "../action-icon/ActionIcon"; import DxcPopover from "../popover/Popover"; import TimePicker from "./TimePicker"; import { pad } from "./utils"; +import ErrorMessage from "../styles/forms/ErrorMessage"; const TimeInputContainer = styled.div<{ size: TimeInputPropsType["size"]; @@ -69,6 +70,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( ref ) => { const inputId = `input-${useId()}`; + const errorId = `error-${useId()}`; const [hourValue, setHourValue] = useState<number | undefined>(undefined); const [minuteValue, setMinuteValue] = useState<number | undefined>(undefined); const [secondValue, setSecondValue] = useState<number | undefined>(undefined); @@ -125,10 +127,6 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setDayPeriodValue(undefined); } if (typeof onChange === "function") { - console.log( - "clear button clicked, value to be emitted: " + - generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined }) - ); onChange(generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined })); } }; @@ -245,7 +243,6 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( tabIndex={tabIndex} /> } - asChild isOpen={isOpen} onClose={() => { setIsOpen(false); @@ -262,7 +259,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( inputId={inputId} tabIndex={tabIndex} dataType="hour" - interactive={!disabled && !readOnly} + readOnly={readOnly} + disabled={disabled} isControlled={isControlled.current} onComplete={() => { if (minuteRef.current) { @@ -299,7 +297,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( inputId={inputId} tabIndex={tabIndex} dataType="minute" - interactive={!disabled && !readOnly} + readOnly={readOnly} + disabled={disabled} isControlled={isControlled.current} onComplete={() => { if (showSeconds && secondRef.current) { @@ -345,7 +344,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( inputId={inputId} tabIndex={tabIndex} dataType="second" - interactive={!disabled && !readOnly} + readOnly={readOnly} + disabled={disabled} isControlled={isControlled.current} onComplete={() => { if (timeFormat === "12" && dayPeriodRef.current) { @@ -388,7 +388,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( inputId={inputId} tabIndex={tabIndex} dataType="dayPeriod" - interactive={!disabled && !readOnly} + readOnly={readOnly} + disabled={disabled} isControlled={isControlled.current} onChange={(value) => { if (!isControlled.current) { @@ -435,8 +436,10 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( </TimeInputField> </DxcPopover> </TimeInputContainer> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} <input aria-label={ariaLabel} + aria-errormessage={error ? errorId : undefined} type="hidden" name={name} value={generateEventValue({ diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 80bd68d9a..72c85297c 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -105,25 +105,25 @@ const TimePicker = ({ const totalHours = timeFormat === "12" ? 12 : 24; useEffect(() => { - if (hourToFocus !== undefined) { - document.getElementById(`${id}-hour-${hourToFocus}`)?.focus(); - } - }, [hourToFocus]); - useEffect(() => { - if (minuteToFocus !== undefined) { - document.getElementById(`${id}-minute-${minuteToFocus}`)?.focus(); + if (dayPeriodToFocus !== undefined) { + document.getElementById(`${id}-dayPeriod-${dayPeriodToFocus}`)?.focus(); } - }, [minuteToFocus]); + }, [dayPeriodToFocus]); useEffect(() => { if (secondToFocus !== undefined) { document.getElementById(`${id}-second-${secondToFocus}`)?.focus(); } }, [secondToFocus]); useEffect(() => { - if (dayPeriodToFocus !== undefined) { - document.getElementById(`${id}-dayPeriod-${dayPeriodToFocus}`)?.focus(); + if (minuteToFocus !== undefined) { + document.getElementById(`${id}-minute-${minuteToFocus}`)?.focus(); } - }, [dayPeriodToFocus]); + }, [minuteToFocus]); + useEffect(() => { + if (hourToFocus !== undefined) { + document.getElementById(`${id}-hour-${hourToFocus}`)?.focus(); + } + }, [hourToFocus]); // Function that returns the hour value based on the index and the format. const returnHourBasedOnIndex = (index: number) => (index + 1 === 24 ? 0 : index + 1); diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 7dd21a81b..7025ac621 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -3,12 +3,19 @@ import { TimeSpinButtonPropsType } from "./types"; import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import { handleKeyDown } from "./utils"; -const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean }>` +const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean; disabled: boolean }>` caret-color: transparent; - color: ${(props) => (props.isPlaceholder ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + color: ${(props) => + props.isPlaceholder + ? "var(--color-fg-neutral-medium)" + : props.disabled + ? "var(--color-fg-neutral-medium)" + : "var(--color-fg-neutral-dark)"}; &:focus { - background-color: var(--color-bg-primary-lighter); - outline: none; + ${(props) => + !props.disabled && + `background-color: var(--color-bg-primary-lighter); + outline: none;`} } `; @@ -36,7 +43,8 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( inputId, tabIndex, dataType, - interactive, + readOnly, + disabled, isControlled, onChange, onComplete, @@ -79,13 +87,10 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( } }, [value]); - // Value used to track the raw input before it's resolved to a valid value. + // Values used to track the raw input before it's resolved to a valid value. const rawInput = useRef<string>(""); const newDigit = useRef<string>(""); - const handleBlur = () => { - rawInput.current = ""; - }; return ( <TimeSpinButtonContainer ref={(node) => { @@ -102,7 +107,8 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( aria-valuemin={minValue} aria-valuemax={maxValue} aria-labelledby={inputId} - contentEditable={interactive ? "plaintext-only" : "false"} + disabled={disabled} + contentEditable={!readOnly && !disabled ? "plaintext-only" : "false"} inputMode={dataType !== "dayPeriod" ? "numeric" : undefined} tabIndex={tabIndex} data-type={dataType} @@ -111,6 +117,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( onKeyDown={(event) => handleKeyDown( event, + !readOnly && !disabled, rawInput, newDigit, spanRef, @@ -125,7 +132,6 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( onPrevious ) } - onBlur={handleBlur} /> ); } diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index ee1a8c670..2f62e6ee5 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -102,7 +102,8 @@ export type TimeSpinButtonPropsType = { inputId: string; tabIndex: number; dataType?: "hour" | "minute" | "second" | "dayPeriod"; - interactive: boolean; + readOnly: boolean; + disabled: boolean; isControlled: boolean; onComplete?: () => void; onChange?: (value: number | undefined) => void; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index d8f19706f..7469fc3c9 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -26,6 +26,7 @@ const checkCompletion = (value: string, maxValue: number) => { export const handleKeyDown = ( event: React.KeyboardEvent<HTMLSpanElement>, + interactive: boolean, rawInput: React.MutableRefObject<string>, newDigit: React.MutableRefObject<string>, spanRef: React.MutableRefObject<HTMLSpanElement | null>, @@ -39,6 +40,7 @@ export const handleKeyDown = ( onNext?: () => void, onPrevious?: () => void ) => { + if (!interactive) return; const input = event.currentTarget; let newValue: number | undefined = innerValue; if (event.key === "Backspace" || event.key === "Delete") { From f2b0473843efafeabcd3d572264f500e904e9e2a Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 23 Apr 2026 10:51:21 +0200 Subject: [PATCH 08/18] Changed token applied to popover --- packages/lib/src/popover/Popover.tsx | 11 ++- .../lib/src/time-input/TimeInput.stories.tsx | 75 ++++--------------- packages/lib/src/time-input/TimeInput.tsx | 4 + 3 files changed, 25 insertions(+), 65 deletions(-) diff --git a/packages/lib/src/popover/Popover.tsx b/packages/lib/src/popover/Popover.tsx index 8fd3247ee..b10496762 100644 --- a/packages/lib/src/popover/Popover.tsx +++ b/packages/lib/src/popover/Popover.tsx @@ -3,11 +3,15 @@ import { useEffect, useId, useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { PopoverPropsType } from "./types"; +const PopoverWrapper = styled.div` + width: fit-content; +`; + const PopoverContent = styled.div` box-sizing: border-box; border-radius: var(--border-radius-m); box-shadow: var(--shadow-400); - padding: var(--spacing-gap-s); + padding: var(--spacing-padding-xs); background-color: var(--color-bg-neutral-lightest); `; @@ -55,9 +59,8 @@ const DxcPopover = ({ {asChild ? ( children ) : ( - <div + <PopoverWrapper role="button" - style={{ width: "fit-content" }} onClick={ actionToOpen === "click" ? () => handleTrigger(isControlled.current, setOpened, true, onOpen) @@ -75,7 +78,7 @@ const DxcPopover = ({ } > {children} - </div> + </PopoverWrapper> )} </Popover.Trigger> <Popover.Portal container={portalContainer}> diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 78219b4e6..71c30e733 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -27,10 +27,9 @@ export default { const TimeInput = () => { const [continentalValue, setContinentalValue] = useState<string>("18:30:20"); const [value] = useState<string>("6:30:20 AM"); - return ( - <> - <ExampleContainer> - <Title title="Default" theme="light" level={2} /> + const TimeInputExamples = () => { + return ( + <> <DxcTimeInput label="Time" helperText="Helper text" @@ -71,72 +70,26 @@ const TimeInput = () => { showSeconds error="This is not a valid time" /> + </> + ); + }; + return ( + <> + <ExampleContainer> + <Title title="Default" theme="light" level={2} /> + <TimeInputExamples /> </ExampleContainer> <ExampleContainer pseudoState={"pseudo-hover"}> <Title title="Hover" theme="light" level={2} /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> - <DxcContainer width="175px"> - <DxcTimeInput - label="Time" - helperText="Helper text" - timeFormat="24" - clearable - value={continentalValue} - onChange={(val) => { - console.log(`Value changed: ${val}`); - setContinentalValue(val); - }} - size="fillParent" - /> - </DxcContainer> - <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + <TimeInputExamples /> </ExampleContainer> <ExampleContainer pseudoState={"pseudo-focus"}> <Title title="Focus" theme="light" level={2} /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> - <DxcContainer width="175px"> - <DxcTimeInput - label="Time" - helperText="Helper text" - timeFormat="24" - clearable - value={continentalValue} - onChange={(val) => { - console.log(`Value changed: ${val}`); - setContinentalValue(val); - }} - size="fillParent" - /> - </DxcContainer> - <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + <TimeInputExamples /> </ExampleContainer> <ExampleContainer pseudoState={"pseudo-active"}> <Title title="Active" theme="light" level={2} /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} size="small" /> - <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> - <DxcContainer width="175px"> - <DxcTimeInput - label="Time" - helperText="Helper text" - timeFormat="24" - clearable - value={continentalValue} - onChange={(val) => { - console.log(`Value changed: ${val}`); - setContinentalValue(val); - }} - size="fillParent" - /> - </DxcContainer> - <DxcTimeInput label="Time" timeFormat="24" helperText="Helper text" showSeconds value={continentalValue} /> + <TimeInputExamples /> </ExampleContainer> </> ); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 27813315a..c4a432fcc 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -25,6 +25,9 @@ const TimeInputContainer = styled.div<{ font-weight: var(--typography-label-regular); color: var(--color-fg-neutral-dark); width: ${({ size }) => calculateWidth(undefined, size)}; + & > div { + width: 100%; + } `; const TimeInputField = styled.div<{ @@ -39,6 +42,7 @@ const TimeInputField = styled.div<{ height: var(--height-m); padding: var(--spacing-padding-none) var(--spacing-padding-xs); ${({ disabled, error, readOnly }) => inputStylesByState(disabled, error, readOnly)} + width: 100%; `; const ColonContainer = styled.span` From 2af83c496b412d8c86e6950b38cf5f4df4c1b388 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 23 Apr 2026 15:36:01 +0200 Subject: [PATCH 09/18] More tests added and code improvements --- .../lib/src/time-input/TimeInput.stories.tsx | 15 +- .../lib/src/time-input/TimeInput.test.tsx | 50 +- packages/lib/src/time-input/TimeInput.tsx | 513 ++++++++---------- .../lib/src/time-input/TimeSpinButton.tsx | 4 +- packages/lib/src/time-input/types.ts | 2 +- packages/lib/src/time-input/utils.ts | 16 + 6 files changed, 306 insertions(+), 294 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 71c30e733..89392cefe 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -33,18 +33,27 @@ const TimeInput = () => { <DxcTimeInput label="Time" helperText="Helper text" - value={value} size="small" onChange={(val) => { console.log(`Value changed: ${val}`); }} onBlur={(val) => { - console.log(`Value blurred: ${val.value}`); + console.log(val); }} clearable /> <DxcTimeInput label="Time" helperText="Helper text" defaultValue={value} showSeconds /> - <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" /> + <DxcTimeInput + label="Time" + helperText="Helper text" + timeFormat="24" + onChange={(val) => { + console.log(`Value changed: ${val}`); + }} + onBlur={(val) => { + console.log(val); + }} + /> <DxcTimeInput label="Time" helperText="Helper text" timeFormat="24" showSeconds size="large" /> <DxcContainer width="175px"> <DxcTimeInput diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index 36c3e1335..4a6367588 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -2,6 +2,7 @@ import { render } from "@testing-library/react"; import DxcTimeInput from "./TimeInput"; import MockDOMRect from "../../test/mocks/domRectMock"; import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; // Mocking DOMRect for Radix Primitive Popover global.DOMRect = MockDOMRect; @@ -75,8 +76,49 @@ describe("DxcTimeInput rendering", () => { expect(mockOnChange).toHaveBeenCalledWith("10:30 AM"); }); - // it("renders error message", () => { - // render(<DxcTimeInput error="Invalid time" />); - // expect(screen.getByText("Invalid time")).toBeInTheDocument(); - // }); + it("renders error message", () => { + const { getByText } = render(<DxcTimeInput error="Invalid time" />); + expect(getByText("Invalid time")).toBeTruthy(); + }); + + it("Calls onBlur with the correct value", () => { + const mockOnBlur = jest.fn(); + const mockOnChange = jest.fn(); + const { getAllByRole } = render(<DxcTimeInput label="Time input" onBlur={mockOnBlur} onChange={mockOnChange} />); + const inputs = getAllByRole("spinbutton"); + expect(inputs).toHaveLength(3); // hour + minute + dayPeriod + userEvent.tab(); + expect(inputs[0]).toHaveFocus(); + userEvent.keyboard("{ArrowUp}"); + expect(mockOnChange).toHaveBeenCalledWith("01:undefined undefined"); + userEvent.tab(); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("{ArrowDown}"); + expect(mockOnChange).toHaveBeenCalledWith("01:59 undefined"); + userEvent.tab(); + expect(inputs[2]).toHaveFocus(); + userEvent.keyboard("{A}"); + expect(mockOnChange).toHaveBeenCalledWith("01:59 AM"); + userEvent.tab(); + expect(mockOnBlur).toHaveBeenCalledWith({ value: "01:59 AM", error: undefined }); + }); + + it("TimePicker keyboard interaction", () => { + const mockOnChange = jest.fn(); + const { getByRole, getByText, getAllByText } = render(<DxcTimeInput label="Time input" onChange={mockOnChange} />); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + expect(getByText("AM")).toBeTruthy(); + const hourbutton = getAllByText("07"); + if (hourbutton[0]) userEvent.click(hourbutton[0]); + expect(mockOnChange).toHaveBeenCalledWith("07:undefined undefined"); + const minuteButton = getAllByText("30"); + if (minuteButton[0]) userEvent.click(minuteButton[0]); + expect(mockOnChange).toHaveBeenCalledWith("07:30 undefined"); + const amButton = getByText("AM"); + expect(amButton).toBeTruthy(); + userEvent.click(amButton); + expect(mockOnChange).toHaveBeenCalledWith("07:30 AM"); + }); }); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index c4a432fcc..27688cc80 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import inputStylesByState from "../styles/forms/inputStylesByState"; import { calculateWidth } from "../text-input/utils"; 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"; @@ -11,7 +11,7 @@ import DxcFlex from "../flex/Flex"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcPopover from "../popover/Popover"; import TimePicker from "./TimePicker"; -import { pad } from "./utils"; +import { generateEventValue } from "./utils"; import ErrorMessage from "../styles/forms/ErrorMessage"; const TimeInputContainer = styled.div<{ @@ -25,9 +25,6 @@ const TimeInputContainer = styled.div<{ font-weight: var(--typography-label-regular); color: var(--color-fg-neutral-dark); width: ${({ size }) => calculateWidth(undefined, size)}; - & > div { - width: 100%; - } `; const TimeInputField = styled.div<{ @@ -42,7 +39,6 @@ const TimeInputField = styled.div<{ height: var(--height-m); padding: var(--spacing-padding-none) var(--spacing-padding-xs); ${({ disabled, error, readOnly }) => inputStylesByState(disabled, error, readOnly)} - width: 100%; `; const ColonContainer = styled.span` @@ -87,25 +83,6 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( const isControlled = useRef(value !== undefined); const translatedLabels = useContext(HalstackLanguageContext); - const generateEventValue = ({ - hour, - minute, - second, - dayPeriod, - }: { - hour?: number; - minute?: number; - second?: number; - dayPeriod?: number; - } = {}) => { - if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { - return ""; - } - return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ - timeFormat === "12" ? ` ${dayPeriod === 0 ? "AM" : "PM"}` : "" - }`; - }; - useEffect(() => { const time = value || defaultValue || undefined; if (time) { @@ -123,6 +100,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }, [value, defaultValue]); + const generatedInputValue = useMemo(() => { + if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { + return ""; + } else { + return generateEventValue(hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat); + } + }, [hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat]); + const handleClearActionOnClick = () => { if (!isControlled.current) { setHourValue(undefined); @@ -131,7 +116,17 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( setDayPeriodValue(undefined); } if (typeof onChange === "function") { - onChange(generateEventValue({ hour: undefined, minute: undefined, second: undefined, dayPeriod: undefined })); + onChange(generateEventValue(undefined, undefined, undefined, undefined, showSeconds, timeFormat)); + } + }; + + const validateTimeValue = (value: string) => { + const timeRegex = + timeFormat === "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)) { + return "Invalid time format"; } }; @@ -143,25 +138,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( onBlur={() => { if (typeof onBlur === "function") { onBlur({ - value: generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: secondValue, - dayPeriod: dayPeriodValue, - }), + value: generatedInputValue, + error: validateTimeValue(generatedInputValue), }); } }} onChange={() => { if (typeof onChange === "function") { - onChange( - generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: secondValue, - dayPeriod: dayPeriodValue, - }) - ); + onChange(generatedInputValue); } }} > @@ -173,262 +157,228 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( {helperText} </HelperText> )} - <DxcPopover - popoverContent={ - <TimePicker - onSelecthours={(value) => { - if (!isControlled.current) { - setHourValue(value); - } - if (typeof onChange === "function") { - onChange( - generateEventValue({ - hour: value, - minute: minuteValue, - second: secondValue, - dayPeriod: dayPeriodValue, - }) - ); - } - }} - onSelectMinutes={(value) => { - if (!isControlled.current) { - setMinuteValue(value); - } - if (typeof onChange === "function") { - onChange( - generateEventValue({ - hour: hourValue, - minute: value, - second: secondValue, - dayPeriod: dayPeriodValue, - }) - ); - } - }} - onSelectSeconds={(value) => { - if (!isControlled.current) { - setSecondValue(value); - } - if (typeof onChange === "function") { - onChange( - generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: value, - dayPeriod: dayPeriodValue, - }) - ); - } - }} - onSelectDayPeriod={(value: number) => { - console.log("selected day period: " + value); - if (!isControlled.current) { - setDayPeriodValue(value); - } - if (typeof onChange === "function") { - onChange( - generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: secondValue, - dayPeriod: value, - }) - ); - } - }} - timeFormat={timeFormat} - showSeconds={showSeconds} - hourValue={hourValue} - minuteValue={minuteValue} - secondValue={secondValue} - dayPeriod={dayPeriodValue} - id={inputId} - tabIndex={tabIndex} - /> - } - isOpen={isOpen} - onClose={() => { - setIsOpen(false); - }} - align="end" - > - <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> - <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center"> - <DxcFlex gap="var(--spacing-gap-xxs)" alignItems="center"> - <TimeSpinButton - value={hourValue} - minValue={timeFormat === "12" ? 1 : 0} - maxValue={timeFormat === "12" ? 12 : 23} - inputId={inputId} - tabIndex={tabIndex} - dataType="hour" - readOnly={readOnly} - disabled={disabled} - isControlled={isControlled.current} - onComplete={() => { - if (minuteRef.current) { - minuteRef.current.focus(); - } - }} - onChange={(value) => { + <TimeInputField disabled={disabled} error={!!error} readOnly={readOnly}> + <DxcFlex gap="var(--spacing-gap-xs)" alignItems="center"> + <DxcFlex gap="var(--spacing-gap-xxs)" alignItems="center"> + <TimeSpinButton + ariaLabel={label ?? ariaLabel} + value={hourValue} + minValue={timeFormat === "12" ? 1 : 0} + maxValue={timeFormat === "12" ? 12 : 23} + tabIndex={tabIndex} + dataType="hour" + readOnly={readOnly} + disabled={disabled} + isControlled={isControlled.current} + onComplete={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} + onChange={(value) => { + if (!isControlled.current) { + setHourValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(value, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onNext={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} + ref={hourRef} + /> + <ColonContainer>:</ColonContainer> + <TimeSpinButton + ariaLabel={label ?? ariaLabel} + value={minuteValue} + minValue={0} + maxValue={59} + tabIndex={tabIndex} + dataType="minute" + readOnly={readOnly} + disabled={disabled} + isControlled={isControlled.current} + onComplete={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onChange={(value) => { + if (!isControlled.current) { + setMinuteValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, value, secondValue, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onNext={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onPrevious={() => { + if (hourRef.current) { + hourRef.current.focus(); + } + }} + ref={minuteRef} + /> + {showSeconds && ( + <> + <ColonContainer>:</ColonContainer> + <TimeSpinButton + ariaLabel={label ?? ariaLabel} + value={secondValue} + minValue={0} + maxValue={59} + tabIndex={tabIndex} + dataType="second" + readOnly={readOnly} + disabled={disabled} + isControlled={isControlled.current} + onComplete={() => { + if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onChange={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, minuteValue, value, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onNext={() => { + if (timeFormat === "12" && dayPeriodRef.current) { + dayPeriodRef.current.focus(); + } + }} + onPrevious={() => { + if (minuteRef.current) { + minuteRef.current.focus(); + } + }} + ref={secondRef} + /> + </> + )} + </DxcFlex> + {timeFormat === "12" && ( + <TimeSpinButton + ariaLabel={label ?? ariaLabel} + value={dayPeriodValue} + minValue={0} + maxValue={1} + tabIndex={tabIndex} + dataType="dayPeriod" + readOnly={readOnly} + disabled={disabled} + isControlled={isControlled.current} + onChange={(value) => { + if (!isControlled.current) { + setDayPeriodValue(value); + } + if (typeof onChange === "function") { + onChange(generateEventValue(hourValue, minuteValue, secondValue, value, showSeconds, timeFormat)); + } + }} + onPrevious={() => { + if (showSeconds && secondRef.current) { + secondRef.current.focus(); + } else if (minuteRef.current) { + minuteRef.current.focus(); + } + }} + ref={dayPeriodRef} + /> + )} + </DxcFlex> + <DxcFlex> + {clearable && ( + <DxcActionIcon + size="xsmall" + icon="close" + onClick={() => handleClearActionOnClick()} + tabIndex={tabIndex} + title={!disabled ? translatedLabels.textInput.clearFieldActionTitle : undefined} + /> + )} + <DxcPopover + popoverContent={ + <TimePicker + onSelecthours={(value) => { if (!isControlled.current) { setHourValue(value); } if (typeof onChange === "function") { onChange( - generateEventValue({ - hour: value, - minute: minuteValue, - second: secondValue, - dayPeriod: dayPeriodValue, - }) + generateEventValue(value, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat) ); } }} - onNext={() => { - if (minuteRef.current) { - minuteRef.current.focus(); - } - }} - ref={hourRef} - /> - <ColonContainer>:</ColonContainer> - <TimeSpinButton - value={minuteValue} - minValue={0} - maxValue={59} - inputId={inputId} - tabIndex={tabIndex} - dataType="minute" - readOnly={readOnly} - disabled={disabled} - isControlled={isControlled.current} - onComplete={() => { - if (showSeconds && secondRef.current) { - secondRef.current.focus(); - } else if (timeFormat === "12" && dayPeriodRef.current) { - dayPeriodRef.current.focus(); - } - }} - onChange={(value) => { + onSelectMinutes={(value) => { if (!isControlled.current) { setMinuteValue(value); } - onChange?.( - generateEventValue({ - hour: hourValue, - minute: value, - second: secondValue, - dayPeriod: dayPeriodValue, - }) - ); - }} - onNext={() => { - if (showSeconds && secondRef.current) { - secondRef.current.focus(); - } else if (timeFormat === "12" && dayPeriodRef.current) { - dayPeriodRef.current.focus(); + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, value, secondValue, dayPeriodValue, showSeconds, timeFormat) + ); } }} - onPrevious={() => { - if (hourRef.current) { - hourRef.current.focus(); + onSelectSeconds={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, minuteValue, value, dayPeriodValue, showSeconds, timeFormat) + ); } }} - ref={minuteRef} - /> - {showSeconds && ( - <> - <ColonContainer>:</ColonContainer> - <TimeSpinButton - value={secondValue} - minValue={0} - maxValue={59} - inputId={inputId} - tabIndex={tabIndex} - dataType="second" - readOnly={readOnly} - disabled={disabled} - isControlled={isControlled.current} - onComplete={() => { - if (timeFormat === "12" && dayPeriodRef.current) { - dayPeriodRef.current.focus(); - } - }} - onChange={(value) => { - if (!isControlled.current) { - setSecondValue(value); - } - onChange?.( - generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: value, - dayPeriod: dayPeriodValue, - }) - ); - }} - onNext={() => { - if (timeFormat === "12" && dayPeriodRef.current) { - dayPeriodRef.current.focus(); - } - }} - onPrevious={() => { - if (minuteRef.current) { - minuteRef.current.focus(); - } - }} - ref={secondRef} - /> - </> - )} - </DxcFlex> - {timeFormat === "12" && ( - <TimeSpinButton - value={dayPeriodValue} - minValue={0} - maxValue={1} - inputId={inputId} - tabIndex={tabIndex} - dataType="dayPeriod" - readOnly={readOnly} - disabled={disabled} - isControlled={isControlled.current} - onChange={(value) => { + onSelectDayPeriod={(value: number) => { + console.log("selected day period: " + value); if (!isControlled.current) { setDayPeriodValue(value); } - onChange?.( - generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: secondValue, - dayPeriod: value, - }) - ); - }} - onPrevious={() => { - if (showSeconds && secondRef.current) { - secondRef.current.focus(); - } else if (minuteRef.current) { - minuteRef.current.focus(); + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, minuteValue, secondValue, value, showSeconds, timeFormat) + ); } }} - ref={dayPeriodRef} - /> - )} - </DxcFlex> - <DxcFlex> - {clearable && ( - <DxcActionIcon - size="xsmall" - icon="close" - onClick={() => handleClearActionOnClick()} + timeFormat={timeFormat} + showSeconds={showSeconds} + hourValue={hourValue} + minuteValue={minuteValue} + secondValue={secondValue} + dayPeriod={dayPeriodValue} + id={inputId} tabIndex={tabIndex} - title={!disabled ? translatedLabels.textInput.clearFieldActionTitle : undefined} /> - )} + } + isOpen={isOpen} + offset={4} + onClose={() => { + setIsOpen(false); + }} + align="end" + asChild + > <DxcActionIcon size="xsmall" disabled={disabled} @@ -436,22 +386,17 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( title="Select time" onClick={() => setIsOpen(true)} /> - </DxcFlex> - </TimeInputField> - </DxcPopover> + </DxcPopover> + </DxcFlex> + </TimeInputField> </TimeInputContainer> {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} <input - aria-label={ariaLabel} + aria-label={label ?? ariaLabel} aria-errormessage={error ? errorId : undefined} type="hidden" name={name} - value={generateEventValue({ - hour: hourValue, - minute: minuteValue, - second: secondValue, - dayPeriod: dayPeriodValue, - })} + value={generatedInputValue} /> </> ); diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 7025ac621..0db8256e0 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -37,10 +37,10 @@ const generateDisplayValue = ( const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( ( { + ariaLabel, value, minValue, maxValue, - inputId, tabIndex, dataType, readOnly, @@ -106,7 +106,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( aria-valuetext={innerValue != null ? String(innerValue) : "Empty"} aria-valuemin={minValue} aria-valuemax={maxValue} - aria-labelledby={inputId} + aria-label={ariaLabel} disabled={disabled} contentEditable={!readOnly && !disabled ? "plaintext-only" : "false"} inputMode={dataType !== "dayPeriod" ? "numeric" : undefined} diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index 2f62e6ee5..a3fd04b97 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -96,10 +96,10 @@ type Props = { export type RefType = HTMLDivElement; export type TimeSpinButtonPropsType = { + ariaLabel?: string; value: number | undefined; minValue: number; maxValue: number; - inputId: string; tabIndex: number; dataType?: "hour" | "minute" | "second" | "dayPeriod"; readOnly: boolean; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 7469fc3c9..bdd54c26a 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -104,3 +104,19 @@ export const handleKeyDown = ( onPrevious(); } }; + +export const generateEventValue = ( + hour: number | undefined, + minute: number | undefined, + second: number | undefined, + dayPeriod: number | undefined, + showSeconds: boolean | undefined, + timeFormat: "12" | "24" | undefined +) => { + if (hour === undefined && minute === undefined && second === undefined && dayPeriod === undefined) { + return ""; + } + return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ + timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : undefined}` : "" + }`; +}; From 9a9ea5ef2f04f31103494efc119cc9907b0fd5d6 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Mon, 27 Apr 2026 14:30:39 +0200 Subject: [PATCH 10/18] Added documentation and improved tests coverage --- apps/website/next-env.d.ts | 2 +- .../pages/components/time-input/code.tsx | 17 ++ .../pages/components/time-input/index.tsx | 17 ++ .../screens/common/componentsList.json | 6 + .../time-input/TimeInputPageLayout.tsx | 27 ++ .../time-input/code/TimeInputCodePage.tsx | 249 ++++++++++++++++ .../time-input/code/examples/controlled.tsx | 27 ++ .../overview/TimeInputOverviewPage.tsx | 271 ++++++++++++++++++ packages/lib/src/index.ts | 1 + .../lib/src/time-input/TimeInput.test.tsx | 122 +++++++- packages/lib/src/time-input/types.ts | 20 +- 11 files changed, 742 insertions(+), 17 deletions(-) create mode 100644 apps/website/pages/components/time-input/code.tsx create mode 100644 apps/website/pages/components/time-input/index.tsx create mode 100644 apps/website/screens/components/time-input/TimeInputPageLayout.tsx create mode 100644 apps/website/screens/components/time-input/code/TimeInputCodePage.tsx create mode 100644 apps/website/screens/components/time-input/code/examples/controlled.tsx create mode 100644 apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index 19709046a..7996d352f 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/website/pages/components/time-input/code.tsx b/apps/website/pages/components/time-input/code.tsx new file mode 100644 index 000000000..b1f524e10 --- /dev/null +++ b/apps/website/pages/components/time-input/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import TimeInputPageLayout from "screens/components/time-input/TimeInputPageLayout"; +import TimeInputCodePage from "screens/components/time-input/code/TimeInputCodePage"; + +const Code = () => ( + <> + <Head> + <title>Time input code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/time-input/index.tsx b/apps/website/pages/components/time-input/index.tsx new file mode 100644 index 000000000..dd72cd24c --- /dev/null +++ b/apps/website/pages/components/time-input/index.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import TimeInputOverviewPage from "screens/components/time-input/overview/TimeInputOverviewPage"; +import TimeInputPageLayout from "screens/components/time-input/TimeInputPageLayout"; + +const Index = () => ( + <> + + Time input — Halstack Design System + + + +); + +Index.getLayout = (page: ReactElement) => {page}; + +export default Index; diff --git a/apps/website/screens/common/componentsList.json b/apps/website/screens/common/componentsList.json index a7707b07a..358874524 100644 --- a/apps/website/screens/common/componentsList.json +++ b/apps/website/screens/common/componentsList.json @@ -189,6 +189,12 @@ "status": "stable", "icon": "subject" }, + { + "label": "Time input", + "path": "/components/time-input", + "status": "experimental", + "icon": "schedule" + }, { "label": "Toggle group", "path": "/components/toggle-group", diff --git a/apps/website/screens/components/time-input/TimeInputPageLayout.tsx b/apps/website/screens/components/time-input/TimeInputPageLayout.tsx new file mode 100644 index 000000000..ea96f7bd8 --- /dev/null +++ b/apps/website/screens/components/time-input/TimeInputPageLayout.tsx @@ -0,0 +1,27 @@ +import { DxcParagraph, DxcFlex } from "@dxc-technology/halstack-react"; +import PageHeading from "@/common/PageHeading"; +import TabsPageHeading from "@/common/TabsPageLayout"; +import ComponentHeading from "@/common/ComponentHeading"; +import { ReactNode } from "react"; + +const TimeInputPageHeading = ({ children }: { children: ReactNode }) => { + const tabs = [ + { label: "Overview", path: "/components/time-input" }, + { label: "Code", path: "/components/time-input/code" }, + ]; + + return ( + + + + + Time input allows users to specify a specific time. + + + + {children} + + ); +}; + +export default TimeInputPageHeading; diff --git a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx new file mode 100644 index 000000000..8a3b3597a --- /dev/null +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -0,0 +1,249 @@ +import { DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import DocFooter from "@/common/DocFooter"; +import Example from "@/common/example/Example"; +import Code, { TableCode } from "@/common/Code"; +import controlled from "./examples/controlled"; + +const sections = [ + { + title: "Props", + content: ( + + + + Name + Type + Description + Default + + + + + ariaLabel + + string + + Specifies a string to be used as the name for the timeInput element when no `label` is provided. + + 'Text input' + + + + clearable + + boolean + + If true, the input will have an action to clear the entered value. + + false + + + + defaultValue + + string + + Initial value of the input, only when it is uncontrolled. + - + + + disabled + + boolean + + If true, the component will be disabled. + + false + + + + error + + string + + + If it is a defined value and also a truthy string, the component will change its appearance, showing the + error below the input component. If the defined value is an empty string, it will reserve a space below + the component for a future error, but it would not change its look. In case of being undefined or null, + both the appearance and the space for the error message would not be modified. + + - + + + helperText + + string + + Helper text to be placed above the input. + - + + + label + + string + + Text to be placed above the input. + - + + + name + + string + + Name attribute of the input element. + - + + + onBlur + + {"(val: { value: string; error?: string }) => void"} + + + This function will be called when the input element loses the focus. An object including the input value + and the error (if the value entered is not valid) will be passed to this function. If there is no error,{" "} + error will not be defined. + + - + + + onChange + + {"(value: string) => void"} + + + This function will be called when the user types within the input or selects a value in the dropdown + element of the component. + + - + + + optional + + boolean + + + If true, the input will be optional, showing '(Optional)' next to the label. Otherwise, the field will be + considered required and an error will be passed as a parameter to the onBlur function when it + has not been filled. + + + false + + + + readOnly + + boolean + + + If true, the component will not be mutable, meaning the user can not edit the control. In addition, the + clear action will not be displayed even if the flag is set to true. + + + false + + + + ref + + {"React.Ref"} + + Reference to the component. + - + + + showSeconds + + boolean + + + If true, the component will display seconds and allow the user to input them. Otherwise, seconds will not + be shown and the user will not be able to input them. + + + false + + + + size + + 'small' | 'medium' | 'large' | 'fillParent' + + Size of the component. + + 'medium' + + + + tabIndex + + number + + + Value of the tabindex attribute. + + + 0 + + + + timeFormat + + '12' | '24' + + Time format of the input. It can be either 12 or 24. + + '12' + + + + value + + string + + + Value of the input. If undefined, the component will be uncontrolled and the value will be managed + internally by the component. + + - + + + + ), + }, + { + title: "Examples", + subSections: [ + { + title: "Controlled", + content: , + }, + // { + // title: "Uncontrolled", + // content: , + // }, + // { + // title: "Action", + // content: , + // }, + // { + // title: "Autosuggest", + // content: , + // }, + // { + // title: "Error handling", + // content: , + // }, + ], + }, +]; + +const TextInputCodePage = () => ( + + + + +); + +export default TextInputCodePage; diff --git a/apps/website/screens/components/time-input/code/examples/controlled.tsx b/apps/website/screens/components/time-input/code/examples/controlled.tsx new file mode 100644 index 000000000..48010e238 --- /dev/null +++ b/apps/website/screens/components/time-input/code/examples/controlled.tsx @@ -0,0 +1,27 @@ +import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, setValue] = useState(""); + const onChange = ({ value }) => { + setValue(value); + }; + + return ( + + + + ); +}`; + +const scope = { + DxcTimeInput, + DxcInset, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx new file mode 100644 index 000000000..216e19da5 --- /dev/null +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -0,0 +1,271 @@ +import { DxcParagraph, DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import DocFooter from "@/common/DocFooter"; +// import Image from "@/common/Image"; +// import Figure from "@/common/Figure"; +// import Example from "@/common/example/Example"; + +const sections = [ + { + title: "Introduction", + content: ( + + Time inputs allow users to enter or select a specific time using a time picker or manual text entry. Designed to + support a wide range of use cases - particularly to support the date input component - from booking systems to + form submissions, using this component ensures clarity and consistency in date and time formats, helps prevent + input errors, and adapts to different locale and accessibility requirements. Its combination of manual input and + guided selection provides flexibility while maintaining a streamlined user experience. + + ), + }, + { + title: "Anatomy", + content: ( + <> + {/* Time input anatomy */} + + + Label (Optional): a descriptive text that helps users understand what information + is expected in the input field. It should be clear, concise, and placed near the input for better + readability. + + + Optional indicator (Optional): a small indicator that signals the input field is + not mandatory. It helps users know they can leave the field empty without causing validation errors. + + + Time button (Optional): an interactive element inside the input field that + triggers the time picker of the component, where the user can select hour, minute, and AM/PM values. + + + Clear action (Optional): a small button, usually represented by an "X" icon, that + allows users to quickly clear the time specified or selected without manually deleting each value. + + + Helper text (Optional): additional text placed below the input field that provides + guidance, examples, or explanations to assist users in filling out the field correctly. + + + Container: the visual wrapper around the input field that provides structure, ensures + accessibility, and helps differentiate the input from other UI elements. + + + Value: displays the selected or manually entered time in the input field, following an + hour, minutes, AM/PM format. + + + + ), + }, + { + title: "Form inputs", + content: ( + <> + + Form inputs are essential UI elements that allow users to interact with digital products by{" "} + entering or selecting data. Choosing the right input type and structure is key to designing + efficient, user-friendly forms that support task completion and data accuracy. + + + A form input (also known as a form field) is used to capture user data. Common input types include text + fields, date pickers, number fields, radio buttons, checkboxes, toggles, and dropdowns. Forms should always + include a submission method, such as a submit button, link, or keyboard trigger, to complete the interaction. + + + ), + subSections: [ + { + title: "Shared input characteristics", + content: ( + <> + + Although input fields vary in type and purpose, they often share a common set of features: + + + + Placeholder: a short hint displayed inside the input field that describes its expected + value or purpose. + + + Helper text: additional information displayed below the field to guide the user in + providing the correct input. + + + Optional label: inputs that are not mandatory can be marked with an "Optional" tag to + set clear expectations. + + + + ), + }, + { + title: "Common input states", + content: ( + <> + Most inputs can also present standard interactive or informative states: + + + Disabled: this state prevents users from interacting with the field. It's typically + used when a value is not applicable or editable under certain conditions or roles. + + + Error: when a user enters invalid or incomplete data, the input shows an error state, + often accompanied by a helpful message to guide corrections. + + + Read-only: the input is visible, focusable, and hoverable, but not editable. This is + ideal for fields with auto-calculated values. Unlike disabled fields, read-only inputs can still be + submitted with the form and are part of the form data. + + + + ), + }, + ], + }, + { + title: "Using time inputs", + content: ( + + Time inputs are designed to help users provide valid, well-formatted times with minimal friction. Similar to the + date inputs, they combine manual input with an interactive picker, making them ideal for scenarios like + bookings, forms, or scheduling events. They are particularly useful for reducing input errors and ensuring + consistent formatting across different regions and use cases. + + ), + subSections: [ + { + title: "Actions", + subSections: [ + { + title: "Clear action", + content: ( + <> + + Similar to the date input, the time input includes a clear (close) icon that allows users to quickly + remove the selected or typed time with a single click. This is especially helpful when correcting + mistakes or resetting the field during form completion. The icon is only visible when a value is + present, keeping the interface clean and focused. + + {/*
+ States for the clear content button +
*/} + + ), + }, + { + title: "Time picker popup", + content: ( + <> + + The component features a built-in time picker dialog that can be opened via the time icon. This dialog + allows users to select the hour, minute, and AM/PM values visually, reducing the likelihood of + formatting errors. The minutes values are presented as 5-minute increments to provide an optimal + balance of selectable items. Users can manually enter minutes values that are not part of the + selectable list. + + {/*
+ States for the time picker popup +
*/} + + ), + }, + ], + }, + ], + }, + { + title: "Best practices", + subSections: [ + { + title: "General", + content: ( + + + Always use the time input when a valid time format is required. This helps ensure consistency and prevents + user error. + + + Display time formats clearly and consistently across your application, especially if users from multiple + locales are expected. + + + Include a clear label that describes the context or purpose of the time (e.g., "Notification time" or + "Start time"). + + + Avoid setting default times unless the context explicitly calls for it, such as pre-filling the current + time for quick scheduling. + + + ), + }, + { + title: "Formatting and validation", + content: ( + + + Provide clear feedback if the user types an invalid time manually. + + + Avoid using text inputs with custom formatting masks in place of the time input component — this can + confuse users and complicate validation. + + + ), + }, + { + title: "Clear action", + content: ( + + + Use the clear (close) icon to let users easily remove an already selected time. This improves usability + for forms where the time might not be required. + + + Ensure the clear icon is only visible when a value is present, keeping the interface clean. + + + ), + }, + { + title: "Time picker dropdown", + content: ( + + + Include the time picker to reduce formatting errors and speed up time selection, especially for less + tech-savvy users or on mobile. + + + You have the option to use a combination of the dropdown (for hours) then manually adjust the minute + values for values that are not part of the selectable list. + + + ), + }, + { + title: "Accessibility and internationalization", + content: ( + + + Provide clear feedback if the user types an invalid time manually. + + + Avoid using text inputs with custom formatting masks in place of the time input component — this can + confuse users and complicate validation. + + + ), + }, + ], + }, +]; + +const TimeInputOverviewPage = () => ( + + + + +); + +export default TimeInputOverviewPage; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index bac31d71f..ed05c0296 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -46,6 +46,7 @@ export { default as DxcTable } from "./table/Table"; export { default as DxcTabs } from "./tabs/Tabs"; export { default as DxcTextarea } from "./textarea/Textarea"; export { default as DxcTextInput } from "./text-input/TextInput"; +export { default as DxcTimeInput } from "./time-input/TimeInput"; export { default as DxcToastsQueue } from "./toast/ToastsQueue"; export { default as DxcToggleGroup } from "./toggle-group/ToggleGroup"; export { default as DxcTooltip } from "./tooltip/Tooltip"; diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index 4a6367588..d00812197 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -56,7 +56,7 @@ describe("DxcTimeInput rendering", () => { expect(mockOnChange).toHaveBeenCalledWith(""); }); - it("renders time picker and values are selected", () => { + it("renders time picker and values are correctly selected", () => { const mockOnChange = jest.fn(); const { getByRole, getAllByRole } = render(); const button = getByRole("button"); @@ -103,7 +103,7 @@ describe("DxcTimeInput rendering", () => { expect(mockOnBlur).toHaveBeenCalledWith({ value: "01:59 AM", error: undefined }); }); - it("TimePicker keyboard interaction", () => { + it("TimePicker click interaction", () => { const mockOnChange = jest.fn(); const { getByRole, getByText, getAllByText } = render(); const button = getByRole("button"); @@ -121,4 +121,122 @@ describe("DxcTimeInput rendering", () => { userEvent.click(amButton); expect(mockOnChange).toHaveBeenCalledWith("07:30 AM"); }); + + it("TimePicker keyboard interaction", () => { + const mockOnChange = jest.fn(); + const { getByRole, getByText } = render(); + const button = getByRole("button"); + expect(button).toBeTruthy(); + userEvent.click(button); + expect(getByText("AM")).toBeTruthy(); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{Enter}"); + expect(mockOnChange).toHaveBeenCalledWith("03:undefined undefined"); + userEvent.tab(); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{Enter}"); + expect(mockOnChange).toHaveBeenCalledWith("03:55 undefined"); + userEvent.tab(); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{Enter}"); + expect(mockOnChange).toHaveBeenCalledWith("03:55 PM"); + }); + + it("TimeInput correctly move focus when each spinbutton is completed", () => { + const mockOnChange = jest.fn(); + const { getAllByRole, getByText } = render( + + ); + const inputs = getAllByRole("spinbutton"); + expect(inputs).toHaveLength(3); + expect(inputs[0]).toHaveValue(23); + expect(inputs[1]).toHaveValue(30); + expect(inputs[2]).toHaveValue(0); + userEvent.click(getByText("23")); + expect(inputs[0]).toHaveFocus(); + userEvent.keyboard("1"); + userEvent.keyboard("0"); + expect(mockOnChange).toHaveBeenCalledWith("10:30:00"); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("{ArrowUp}"); + expect(mockOnChange).toHaveBeenCalledWith("10:31:00"); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{ArrowDown}"); + expect(mockOnChange).toHaveBeenCalledWith("10:29:00"); + userEvent.keyboard("4"); + userEvent.keyboard("5"); + expect(mockOnChange).toHaveBeenCalledWith("10:45:00"); + expect(inputs[2]).toHaveFocus(); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{ArrowUp}"); + expect(mockOnChange).toHaveBeenCalledWith("10:45:01"); + userEvent.keyboard("3"); + userEvent.keyboard("0"); + expect(mockOnChange).toHaveBeenCalledWith("10:45:30"); + expect(inputs[2]).toHaveFocus(); + const buttons = getAllByRole("button"); + expect(buttons).toHaveLength(2); + if (buttons[0]) userEvent.click(buttons[0]); + expect(mockOnChange).toHaveBeenCalledWith(""); + expect(inputs[0]?.getAttribute("aria-valuenow")).toBeNull(); + expect(inputs[1]?.getAttribute("aria-valuenow")).toBeNull(); + expect(inputs[2]?.getAttribute("aria-valuenow")).toBeNull(); + }); + + it("Navigate timeInput using the keyboard", () => { + const mockOnChange = jest.fn(); + const { getAllByRole } = render( + + ); + const inputs = getAllByRole("spinbutton"); + expect(inputs).toHaveLength(4); + expect(inputs[0]).toHaveValue(10); + expect(inputs[1]).toHaveValue(30); + expect(inputs[2]).toHaveValue(0); + expect(inputs[3]).toHaveValue(0); // AM + userEvent.tab(); + expect(inputs[0]).toHaveFocus(); + userEvent.keyboard("{ArrowRight}"); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("{ArrowRight}"); + expect(inputs[2]).toHaveFocus(); + userEvent.keyboard("{ArrowRight}"); + expect(inputs[3]).toHaveFocus(); + userEvent.keyboard("{ArrowLeft}"); + expect(inputs[2]).toHaveFocus(); + userEvent.keyboard("{ArrowLeft}"); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("{ArrowLeft}"); + expect(inputs[0]).toHaveFocus(); + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + expect(inputs[3]).toHaveFocus(); + const buttons = getAllByRole("button"); + expect(buttons).toHaveLength(2); + userEvent.tab(); + expect(buttons[0]).toHaveFocus(); + userEvent.tab(); + expect(buttons[1]).toHaveFocus(); + }); }); diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index a3fd04b97..cb74908fa 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -1,13 +1,8 @@ type Props = { /** - * Specifies a string to be used as the name for the textInput element when no `label` is provided. + * Specifies a string to be used as the name for the timeInput element when no `label` is provided. */ ariaLabel?: string; - /** - * HTML autocomplete attribute. Lets the user specify if any permission the user agent has to provide automated assistance in filling out the input value. - * Its value must be one of all the possible values of the HTML autocomplete attribute: 'on', 'off', 'email', 'username', 'new-password', ... - */ - autocomplete?: string; /** * If true, the input will have an action to clear the entered value. */ @@ -34,7 +29,7 @@ type Props = { */ helperText?: string; /** - * Text to be placed above the input. This label will be used as the aria-label attribute of the list of suggestions. + * Text to be placed above the input. */ label?: string; /** @@ -50,26 +45,23 @@ type Props = { onBlur?: (val: { value: string; error?: string }) => void; /** * This function will be called when the user types within the input - * element of the component. An object including the current value and - * the error (if the value entered is not valid) will be passed to this - * function. If there is no error, error will not be defined. + * or selects a value in the dropdown element of the component. */ onChange?: (value: string) => void; /** * If true, the input will be optional, showing '(Optional)' * next to the label. Otherwise, the field will be considered required and an error will be - * passed as a parameter to the OnBlur and onChange functions when it has + * passed as a parameter to the OnBlur function when it has * not been filled. */ optional?: boolean; /** * If true, the component will not be mutable, meaning the user can not edit the control. - * In addition, the clear action will not be displayed even if the flag is set to true - * and the custom action will not execute its onClick event. + * In addition, the clear action will not be displayed even if the flag is set to true. */ readOnly?: boolean; /** - * If true, the input will display seconds. + * If true, the component will display seconds and allow the user to input them. Otherwise, seconds will not be shown and the user will not be able to input them. */ showSeconds?: boolean; /** From 41a70e65fec145d68f55a6f04cc0ebd8933c76ae Mon Sep 17 00:00:00 2001 From: Jialecl Date: Mon, 27 Apr 2026 16:39:32 +0200 Subject: [PATCH 11/18] New examples added to documentation --- .../time-input/code/TimeInputCodePage.tsx | 31 +++++++++---------- .../time-input/code/examples/format.tsx | 26 ++++++++++++++++ .../time-input/code/examples/uncontrolled.tsx | 26 ++++++++++++++++ .../time-input/code/examples/withSeconds.tsx | 27 ++++++++++++++++ packages/lib/src/time-input/TimeInput.tsx | 1 - 5 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 apps/website/screens/components/time-input/code/examples/format.tsx create mode 100644 apps/website/screens/components/time-input/code/examples/uncontrolled.tsx create mode 100644 apps/website/screens/components/time-input/code/examples/withSeconds.tsx diff --git a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx index 8a3b3597a..32dd70c69 100644 --- a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -4,6 +4,9 @@ import DocFooter from "@/common/DocFooter"; import Example from "@/common/example/Example"; import Code, { TableCode } from "@/common/Code"; import controlled from "./examples/controlled"; +import uncontrolled from "./examples/uncontrolled"; +import format from "./examples/format"; +import withSeconds from "./examples/withSeconds"; const sections = [ { @@ -219,22 +222,18 @@ const sections = [ title: "Controlled", content: , }, - // { - // title: "Uncontrolled", - // content: , - // }, - // { - // title: "Action", - // content: , - // }, - // { - // title: "Autosuggest", - // content: , - // }, - // { - // title: "Error handling", - // content: , - // }, + { + title: "Uncontrolled", + content: , + }, + { + title: "24h format", + content: , + }, + { + title: "With seconds", + content: , + }, ], }, ]; diff --git a/apps/website/screens/components/time-input/code/examples/format.tsx b/apps/website/screens/components/time-input/code/examples/format.tsx new file mode 100644 index 000000000..a9c3dfa5c --- /dev/null +++ b/apps/website/screens/components/time-input/code/examples/format.tsx @@ -0,0 +1,26 @@ +import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const onChange = ({ value }) => { + console.log(value); + }; + + return ( + + + + ); +}`; + +const scope = { + DxcTimeInput, + DxcInset, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx b/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx new file mode 100644 index 000000000..88980803e --- /dev/null +++ b/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx @@ -0,0 +1,26 @@ +import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const onChange = ({ value }) => { + console.log(value); + }; + + return ( + + + + ); +}`; + +const scope = { + DxcTimeInput, + DxcInset, + useState, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/time-input/code/examples/withSeconds.tsx b/apps/website/screens/components/time-input/code/examples/withSeconds.tsx new file mode 100644 index 000000000..0ccd3adc7 --- /dev/null +++ b/apps/website/screens/components/time-input/code/examples/withSeconds.tsx @@ -0,0 +1,27 @@ +import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const onChange = ({ value }) => { + console.log(value); + }; + + return ( + + + + ); +}`; + +const scope = { + DxcTimeInput, + DxcInset, + useState, +}; + +export default { code, scope }; diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 27688cc80..07fadb555 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -351,7 +351,6 @@ const DxcTimeInput = forwardRef( } }} onSelectDayPeriod={(value: number) => { - console.log("selected day period: " + value); if (!isControlled.current) { setDayPeriodValue(value); } From a1732f34dea90a184e06af950895025a69d55ced Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 09:18:01 +0200 Subject: [PATCH 12/18] Adding step value for the time picker --- .../lib/src/time-input/TimeInput.test.tsx | 9 +++--- packages/lib/src/time-input/TimeInput.tsx | 11 ++++++- packages/lib/src/time-input/TimePicker.tsx | 32 ++++++++++++------- .../lib/src/time-input/TimeSpinButton.tsx | 6 +++- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index d00812197..7a0534a28 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -135,16 +135,15 @@ describe("DxcTimeInput rendering", () => { expect(mockOnChange).toHaveBeenCalledWith("03:undefined undefined"); userEvent.tab(); userEvent.keyboard("{ArrowUp}"); - userEvent.keyboard("{ArrowUp}"); - userEvent.keyboard("{ArrowUp}"); - userEvent.keyboard("{ArrowUp}"); - userEvent.keyboard("{ArrowUp}"); userEvent.keyboard("{Enter}"); expect(mockOnChange).toHaveBeenCalledWith("03:55 undefined"); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard(" "); + expect(mockOnChange).toHaveBeenCalledWith("03:00 undefined"); userEvent.tab(); userEvent.keyboard("{ArrowDown}"); userEvent.keyboard("{Enter}"); - expect(mockOnChange).toHaveBeenCalledWith("03:55 PM"); + expect(mockOnChange).toHaveBeenCalledWith("03:00 PM"); }); it("TimeInput correctly move focus when each spinbutton is completed", () => { diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 07fadb555..e12dbb335 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -128,6 +128,15 @@ const DxcTimeInput = forwardRef( if (!timeRegex.test(value)) { return "Invalid time format"; } + if ( + !optional && + (hourValue === undefined || + minuteValue === undefined || + (showSeconds && secondValue === undefined) || + (timeFormat === "12" && dayPeriodValue === undefined)) + ) { + return "This field is required"; + } }; return ( @@ -371,7 +380,7 @@ const DxcTimeInput = forwardRef( /> } isOpen={isOpen} - offset={4} + offset={8} onClose={() => { setIsOpen(false); }} diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 72c85297c..869f1a4b5 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -4,6 +4,10 @@ import DxcContainer from "../container/Container"; import DxcFlex from "../flex/Flex"; import { useEffect, useState } from "react"; +// Array to be used in seconds and minutes. +const STEP = 5; +const ARRAY_OF_60 = Array.from({ length: 60 / STEP }, (_, index) => index * STEP); + const TimePickerContainer = styled.div` display: flex; height: 200px; @@ -49,32 +53,34 @@ const handleColumnKeyDown = ( focusedValue: number, totalValues: number, setValueToFocus: React.Dispatch>, - onSelect?: (value: number) => void + onSelect?: (value: number) => void, + step?: number ) => { + const stepValue = step || 1; // ignore tab key to allow normal tab behavior, and prevent default for other keys to manage focus manually if (!["Tab"].includes(event.key)) event.preventDefault(); if (event.key === "ArrowDown") { if (column === "hour" && focusedValue === 23) { setValueToFocus(0); } else if (column === "hour") { - const newValue = focusedValue + 1 > totalValues ? 1 : focusedValue + 1; + const newValue = focusedValue + stepValue > totalValues ? stepValue : focusedValue + stepValue; setValueToFocus((prev) => (prev === undefined ? 1 : newValue)); - } else if (focusedValue === totalValues - 1) { + } else if (focusedValue === totalValues - stepValue) { setValueToFocus(0); } else { - const newValue = focusedValue + 1 > totalValues - 1 ? 0 : focusedValue + 1; + const newValue = focusedValue + stepValue > totalValues - stepValue ? 0 : focusedValue + stepValue; setValueToFocus(newValue); } } else if (event.key === "ArrowUp") { if (column === "hour" && focusedValue === 0) { setValueToFocus(23); } else if (column === "hour") { - const newValue = focusedValue - 1 < 0 ? totalValues - 1 : focusedValue - 1; - setValueToFocus((prev) => (prev === undefined ? totalValues - 1 : newValue)); + const newValue = focusedValue - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; + setValueToFocus((prev) => (prev === undefined ? totalValues - stepValue : newValue)); } else if (focusedValue === 0) { - setValueToFocus(totalValues - 1); + setValueToFocus(totalValues - stepValue); } else { - const newValue = focusedValue - 1 < 0 ? totalValues - 1 : focusedValue - 1; + const newValue = focusedValue - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; setValueToFocus(newValue); } } else if (["Enter", " "].includes(event.key)) { @@ -162,7 +168,7 @@ const TimePicker = ({ - {Array.from({ length: 60 }, (_, index) => ( + {ARRAY_OF_60.map((index) => ( handleColumnKeyDown(event, "minute", index, 60, setMinuteToFocus, onSelectMinutes)} + onKeyDown={(event) => + handleColumnKeyDown(event, "minute", index, 60, setMinuteToFocus, onSelectMinutes, STEP) + } > {index < 10 ? `0${index}` : index} @@ -184,7 +192,7 @@ const TimePicker = ({ {showSeconds && ( - {Array.from({ length: 60 }, (_, index) => ( + {ARRAY_OF_60.map((index) => ( - handleColumnKeyDown(event, "second", index, 60, setSecondToFocus, onSelectSeconds) + handleColumnKeyDown(event, "second", index, 60, setSecondToFocus, onSelectSeconds, STEP) } > {index < 10 ? `0${index}` : index} diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 0db8256e0..f8e3c1f17 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -59,7 +59,11 @@ const TimeSpinButton = forwardRef( const placeholder = useMemo(() => { switch (dataType) { case "hour": - return "hh"; + if (maxValue === 12) { + return "hh"; + } else { + return "HH"; + } case "minute": return "mm"; case "second": From d2d265e26ac6804431a743818031e84efb03fc93 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 10:22:24 +0200 Subject: [PATCH 13/18] Changing option role Co-authored-by: Copilot --- packages/lib/src/time-input/TimePicker.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 869f1a4b5..bad50608b 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -14,7 +14,7 @@ const TimePickerContainer = styled.div` gap: var(--spacing-gap-m); `; -const TimePickerOption = styled.button<{ +const TimePickerOption = styled.li<{ selected: boolean; }>` display: inline-flex; @@ -135,11 +135,12 @@ const TimePicker = ({ const returnHourBasedOnIndex = (index: number) => (index + 1 === 24 ? 0 : index + 1); return ( - + {Array.from({ length: totalHours }, (_, index) => ( {ARRAY_OF_60.map((index) => ( {ARRAY_OF_60.map((index) => ( {["AM", "PM"].map((period) => ( Date: Tue, 28 Apr 2026 10:45:35 +0200 Subject: [PATCH 14/18] picking the correct role in tests --- packages/lib/src/time-input/TimeInput.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index 7a0534a28..6897c3fa7 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -59,19 +59,19 @@ describe("DxcTimeInput rendering", () => { it("renders time picker and values are correctly selected", () => { const mockOnChange = jest.fn(); const { getByRole, getAllByRole } = render(); - const button = getByRole("button"); - expect(button).toBeTruthy(); - userEvent.click(button); - const hourButton = getAllByRole("button", { name: "05" }).find((hourButton) => hourButton.id.includes("hour")); - const minuteButton = getAllByRole("button", { name: "30" }).find((minuteButton) => + const pickerButton = getByRole("button"); + expect(pickerButton).toBeTruthy(); + userEvent.click(pickerButton); + const hourButton = getAllByRole("option", { name: "05" }).find((hourButton) => hourButton.id.includes("hour")); + const minuteButton = getAllByRole("option", { name: "30" }).find((minuteButton) => minuteButton.id.includes("minute") ); - const amButton = getByRole("button", { name: "AM" }); + const amButton = getByRole("option", { name: "AM" }); expect(hourButton?.getAttribute("aria-selected")).toBe("true"); expect(minuteButton?.getAttribute("aria-selected")).toBe("true"); expect(amButton?.getAttribute("aria-selected")).toBe("true"); - const newHourButton = getAllByRole("button", { name: "10" }).find((hourButton) => hourButton.id.includes("hour")); + const newHourButton = getAllByRole("option", { name: "10" }).find((hourButton) => hourButton.id.includes("hour")); if (newHourButton) userEvent.click(newHourButton); expect(mockOnChange).toHaveBeenCalledWith("10:30 AM"); }); From 3cc17ff4eab377e6a482b3adb21a78e665018aeb Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 11:20:26 +0200 Subject: [PATCH 15/18] refactoring code to make it more readable Co-authored-by: Copilot --- packages/lib/src/time-input/TimePicker.tsx | 218 ++++++------------ .../lib/src/time-input/TimePickerColumn.tsx | 102 ++++++++ 2 files changed, 167 insertions(+), 153 deletions(-) create mode 100644 packages/lib/src/time-input/TimePickerColumn.tsx diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index bad50608b..11c888e96 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -1,8 +1,7 @@ import styled from "@emotion/styled"; import { TimePickerPropsType } from "./types"; -import DxcContainer from "../container/Container"; -import DxcFlex from "../flex/Flex"; import { useEffect, useState } from "react"; +import TimePickerColumn from "./TimePickerColumn"; // Array to be used in seconds and minutes. const STEP = 5; @@ -13,40 +12,6 @@ const TimePickerContainer = styled.div` height: 200px; gap: var(--spacing-gap-m); `; - -const TimePickerOption = styled.li<{ - selected: boolean; -}>` - display: inline-flex; - justify-content: center; - align-items: center; - width: 32px; - height: var(--height-m); - padding: 0; - border: none; - border-radius: var(--border-radius-xl); - cursor: pointer; - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-regular); - background-color: ${(props) => (props.selected ? "var(--color-bg-primary-strong);" : "transparent")}; - color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; - - &:focus { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: calc(var(--border-width-m) * -1); - } - &:hover { - background-color: ${(props) => - props.selected ? "var(--color-bg-primary-strong);" : "var(--color-bg-primary-lighter);"}; - color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; - } - &:active { - background-color: var(--color-bg-primary-stronger); - color: var(--color-fg-neutral-bright); - } -`; - const handleColumnKeyDown = ( event: React.KeyboardEvent, column: string, @@ -131,128 +96,75 @@ const TimePicker = ({ } }, [hourToFocus]); - // Function that returns the hour value based on the index and the format. - const returnHourBasedOnIndex = (index: number) => (index + 1 === 24 ? 0 : index + 1); - return ( - - - {Array.from({ length: totalHours }, (_, index) => ( - { - onSelecthours(returnHourBasedOnIndex(index)); - setHourToFocus(returnHourBasedOnIndex(index)); - }} - onKeyDown={(event) => - handleColumnKeyDown( - event, - "hour", - returnHourBasedOnIndex(index), - totalHours, - setHourToFocus, - onSelecthours - ) - } - > - {index + 1 === 24 ? "00" : index + 1 < 10 ? `0${index + 1}` : index + 1} - - ))} - - - - - {ARRAY_OF_60.map((index) => ( - { - onSelectMinutes(index); - setMinuteToFocus(index); - }} - onKeyDown={(event) => - handleColumnKeyDown(event, "minute", index, 60, setMinuteToFocus, onSelectMinutes, STEP) - } - > - {index < 10 ? `0${index}` : index} - - ))} - - + index)} + id={id} + selectedValue={hourValue} + valueToFocus={hourToFocus} + tabIndex={tabIndex} + dataType="hour" + onClick={(value: number) => { + onSelecthours(value); + setHourToFocus(value); + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "hour", value, totalHours, setHourToFocus, onSelecthours) + } + /> + { + onSelectMinutes(value); + setMinuteToFocus(value); + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "minute", value, 60, setMinuteToFocus, onSelectMinutes, STEP) + } + /> {showSeconds && ( - - - {ARRAY_OF_60.map((index) => ( - { - if (typeof onSelectSeconds === "function") { - onSelectSeconds(index); - setSecondToFocus(index); - } - }} - onKeyDown={(event) => - handleColumnKeyDown(event, "second", index, 60, setSecondToFocus, onSelectSeconds, STEP) - } - > - {index < 10 ? `0${index}` : index} - - ))} - - + { + if (typeof onSelectSeconds === "function") { + onSelectSeconds(value); + setSecondToFocus(value); + } + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "minute", value, 60, setMinuteToFocus, onSelectMinutes, STEP) + } + /> )} {timeFormat === "12" && ( - - - {["AM", "PM"].map((period) => ( - { - if (typeof onSelectDayPeriod === "function") { - onSelectDayPeriod(period === "AM" ? 0 : 1); - setDayPeriodToFocus(period === "AM" ? 0 : 1); - } - }} - onKeyDown={(event) => - handleColumnKeyDown( - event, - "dayPeriod", - period === "AM" ? 0 : 1, - 2, - setDayPeriodToFocus, - onSelectDayPeriod - ) - } - > - {period} - - ))} - - + { + if (typeof onSelectDayPeriod === "function") { + onSelectDayPeriod(value); + setDayPeriodToFocus(value); + } + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "dayPeriod", value, 2, setDayPeriodToFocus, onSelectDayPeriod) + } + /> )} ); diff --git a/packages/lib/src/time-input/TimePickerColumn.tsx b/packages/lib/src/time-input/TimePickerColumn.tsx new file mode 100644 index 000000000..d17f4e805 --- /dev/null +++ b/packages/lib/src/time-input/TimePickerColumn.tsx @@ -0,0 +1,102 @@ +import styled from "@emotion/styled"; +import DxcContainer from "../container/Container"; +import DxcFlex from "../flex/Flex"; +import { pad } from "./utils"; + +const TimePickerOption = styled.li<{ + selected: boolean; +}>` + display: inline-flex; + justify-content: center; + align-items: center; + width: 32px; + height: var(--height-m); + padding: 0; + border: none; + border-radius: var(--border-radius-xl); + cursor: pointer; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-regular); + background-color: ${(props) => (props.selected ? "var(--color-bg-primary-strong);" : "transparent")}; + color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; + + &:focus { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: calc(var(--border-width-m) * -1); + } + &:hover { + background-color: ${(props) => + props.selected ? "var(--color-bg-primary-strong);" : "var(--color-bg-primary-lighter);"}; + color: ${(props) => (props.selected ? "var(--color-fg-neutral-bright);" : "var(--color-fg-neutral-dark);")}; + } + &:active { + background-color: var(--color-bg-primary-stronger); + color: var(--color-fg-neutral-bright); + } +`; + +const returnHourBasedOnIndex = (index: number, dataType: "hour" | "minute" | "second" | "dayPeriod") => { + if (dataType === "hour") { + return index + 1 === 24 ? 0 : index + 1; + } else if (dataType === "dayPeriod") { + return index === 0 ? 0 : 1; + } else { + return index; + } +}; + +const returnDayPeriod = (value: number) => { + return value === 0 ? "AM" : value === 1 ? "PM" : ""; +}; + +const TimePickerColumn = ({ + valuesArray, + id, + selectedValue, + valueToFocus, + tabIndex, + dataType, + onClick, + onKeyboardEvent, +}: { + valuesArray: number[]; + id?: string; + selectedValue?: number; + valueToFocus: number; + tabIndex: number; + dataType: "hour" | "minute" | "second" | "dayPeriod"; + onClick: (value: number) => void; + onKeyboardEvent: (event: React.KeyboardEvent, value: number) => void; +}) => { + return ( + + + {valuesArray.map((optionValue) => ( + { + onClick(returnHourBasedOnIndex(optionValue, dataType)); + }} + onKeyDown={(event) => { + if (typeof onKeyboardEvent === "function") + onKeyboardEvent(event, returnHourBasedOnIndex(optionValue, dataType)); + }} + > + {dataType !== "dayPeriod" + ? pad(returnHourBasedOnIndex(optionValue, dataType)) + : returnDayPeriod(returnHourBasedOnIndex(optionValue, dataType))} + + ))} + + + ); +}; + +export default TimePickerColumn; From 4c3ad370fa7b573e9bea4ceb2b789161266f5dd1 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 12:07:31 +0200 Subject: [PATCH 16/18] Images added to doc --- .../overview/TimeInputOverviewPage.tsx | 34 +++++++++--------- .../overview/images/time_input_anatomy.png | Bin 0 -> 21707 bytes .../overview/images/time_picker_popup.png | Bin 0 -> 93455 bytes .../lib/src/time-input/TimeInput.stories.tsx | 8 ++--- packages/lib/src/time-input/TimeInput.tsx | 14 ++++---- 5 files changed, 26 insertions(+), 30 deletions(-) create mode 100644 apps/website/screens/components/time-input/overview/images/time_input_anatomy.png create mode 100644 apps/website/screens/components/time-input/overview/images/time_picker_popup.png diff --git a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx index 216e19da5..903174b6b 100644 --- a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -1,9 +1,10 @@ import { DxcParagraph, DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; import QuickNavContainer from "@/common/QuickNavContainer"; import DocFooter from "@/common/DocFooter"; -// import Image from "@/common/Image"; -// import Figure from "@/common/Figure"; -// import Example from "@/common/example/Example"; +import Image from "@/common/Image"; +import Figure from "@/common/Figure"; +import anatomy from "./images/time_input_anatomy.png"; +import timeInputTimePickerPopup from "./images/time_picker_popup.png"; const sections = [ { @@ -11,10 +12,10 @@ const sections = [ content: ( Time inputs allow users to enter or select a specific time using a time picker or manual text entry. Designed to - support a wide range of use cases - particularly to support the date input component - from booking systems to - form submissions, using this component ensures clarity and consistency in date and time formats, helps prevent - input errors, and adapts to different locale and accessibility requirements. Its combination of manual input and - guided selection provides flexibility while maintaining a streamlined user experience. + support a wide range of use cases - particularly in working with the date input component - from booking systems + to form submissions, using this component ensures clarity and consistency in date and time formats, helps + prevent input errors, and adapts to different locale and accessibility requirements. Its combination of manual + input and guided selection provides flexibility while maintaining a streamlined user experience. ), }, @@ -22,7 +23,7 @@ const sections = [ title: "Anatomy", content: ( <> - {/* Time input anatomy */} + Time input anatomy Label (Optional): a descriptive text that helps users understand what information @@ -33,14 +34,14 @@ const sections = [ Optional indicator (Optional): a small indicator that signals the input field is not mandatory. It helps users know they can leave the field empty without causing validation errors. - - Time button (Optional): an interactive element inside the input field that - triggers the time picker of the component, where the user can select hour, minute, and AM/PM values. - Clear action (Optional): a small button, usually represented by an "X" icon, that allows users to quickly clear the time specified or selected without manually deleting each value. + + Time button: an interactive element inside the input field that triggers the time picker of + the component, where the user can select hour, minute, and AM/PM values. + Helper text (Optional): additional text placed below the input field that provides guidance, examples, or explanations to assist users in filling out the field correctly. @@ -147,9 +148,6 @@ const sections = [ mistakes or resetting the field during form completion. The icon is only visible when a value is present, keeping the interface clean and focused. - {/*
- States for the clear content button -
*/} ), }, @@ -162,11 +160,11 @@ const sections = [ allows users to select the hour, minute, and AM/PM values visually, reducing the likelihood of formatting errors. The minutes values are presented as 5-minute increments to provide an optimal balance of selectable items. Users can manually enter minutes values that are not part of the - selectable list. + selectable list. The 24-hour variant does not include the AM/PM selection. - {/*
+
States for the time picker popup -
*/} +
), }, diff --git a/apps/website/screens/components/time-input/overview/images/time_input_anatomy.png b/apps/website/screens/components/time-input/overview/images/time_input_anatomy.png new file mode 100644 index 0000000000000000000000000000000000000000..23e4449e0553dd578633ee4d77020d272e532728 GIT binary patch literal 21707 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 z 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 const TimeInput = () => { - const [continentalValue, setContinentalValue] = useState("18:30:20"); - const [value] = useState("6:30:20 AM"); + const continentalValue = "18:30:20"; + const value = "6:30:20 AM"; const TimeInputExamples = () => { return ( <> @@ -61,10 +60,9 @@ const TimeInput = () => { helperText="Helper text" timeFormat="24" clearable - value={continentalValue} + defaultValue={continentalValue} onChange={(val) => { console.log(`Value changed: ${val}`); - setContinentalValue(val); }} size="fillParent" /> diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index e12dbb335..581b9f66a 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import inputStylesByState from "../styles/forms/inputStylesByState"; import { calculateWidth } from "../text-input/utils"; import TimeInputPropsType, { RefType } from "./types"; -import { forwardRef, useContext, useEffect, useId, useMemo, useRef, useState } from "react"; +import { forwardRef, useContext, useEffect, useId, useRef, useState } from "react"; import { HalstackLanguageContext } from "../HalstackContext"; import Label from "../styles/forms/Label"; import HelperText from "../styles/forms/HelperText"; @@ -100,13 +100,13 @@ const DxcTimeInput = forwardRef( } }, [value, defaultValue]); - const generatedInputValue = useMemo(() => { + const generatedInputValue = () => { if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { return ""; } else { return generateEventValue(hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat); } - }, [hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat]); + }; const handleClearActionOnClick = () => { if (!isControlled.current) { @@ -147,14 +147,14 @@ const DxcTimeInput = forwardRef( onBlur={() => { if (typeof onBlur === "function") { onBlur({ - value: generatedInputValue, - error: validateTimeValue(generatedInputValue), + value: generatedInputValue(), + error: validateTimeValue(generatedInputValue()), }); } }} onChange={() => { if (typeof onChange === "function") { - onChange(generatedInputValue); + onChange(generatedInputValue()); } }} > @@ -404,7 +404,7 @@ const DxcTimeInput = forwardRef( aria-errormessage={error ? errorId : undefined} type="hidden" name={name} - value={generatedInputValue} + value={generatedInputValue()} /> ); From 2c4d6404cc11b5bb32da569b70e303053c296bff Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 12:54:46 +0200 Subject: [PATCH 17/18] Fixed empty value behavior Co-authored-by: Copilot --- .../components/time-input/code/examples/controlled.tsx | 5 +---- .../components/time-input/code/examples/format.tsx | 2 +- .../components/time-input/code/examples/uncontrolled.tsx | 2 +- .../components/time-input/code/examples/withSeconds.tsx | 2 +- packages/lib/src/time-input/TimeInput.tsx | 8 ++++---- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/website/screens/components/time-input/code/examples/controlled.tsx b/apps/website/screens/components/time-input/code/examples/controlled.tsx index 48010e238..a9d69363c 100644 --- a/apps/website/screens/components/time-input/code/examples/controlled.tsx +++ b/apps/website/screens/components/time-input/code/examples/controlled.tsx @@ -3,16 +3,13 @@ import { useState } from "react"; const code = `() => { const [value, setValue] = useState(""); - const onChange = ({ value }) => { - setValue(value); - }; return ( setValue(newValue)} /> ); diff --git a/apps/website/screens/components/time-input/code/examples/format.tsx b/apps/website/screens/components/time-input/code/examples/format.tsx index a9c3dfa5c..02d1960a2 100644 --- a/apps/website/screens/components/time-input/code/examples/format.tsx +++ b/apps/website/screens/components/time-input/code/examples/format.tsx @@ -2,7 +2,7 @@ import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; import { useState } from "react"; const code = `() => { - const onChange = ({ value }) => { + const onChange = (value) => { console.log(value); }; diff --git a/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx b/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx index 88980803e..9f00e075a 100644 --- a/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx +++ b/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx @@ -2,7 +2,7 @@ import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; import { useState } from "react"; const code = `() => { - const onChange = ({ value }) => { + const onChange = (value) => { console.log(value); }; diff --git a/apps/website/screens/components/time-input/code/examples/withSeconds.tsx b/apps/website/screens/components/time-input/code/examples/withSeconds.tsx index 0ccd3adc7..f99a63101 100644 --- a/apps/website/screens/components/time-input/code/examples/withSeconds.tsx +++ b/apps/website/screens/components/time-input/code/examples/withSeconds.tsx @@ -2,7 +2,7 @@ import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; import { useState } from "react"; const code = `() => { - const onChange = ({ value }) => { + const onChange = (value) => { console.log(value); }; diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 581b9f66a..ba2e44508 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -89,12 +89,12 @@ const DxcTimeInput = forwardRef( const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; if (numberPart) { const [hour, minute, second] = numberPart.split(":").map(Number); - setHourValue(hour); - setMinuteValue(minute); - setSecondValue(second); + setHourValue(hour ? hour : undefined); + setMinuteValue(minute ? minute : undefined); + setSecondValue(second ? second : undefined); } if (timeFormat === "12" && time.includes(" ")) { - const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : 1; + const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; setDayPeriodValue(dayPeriodValue); } } From 83bfc7459450af0e0b91d1a66bf406e214bd0225 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Tue, 28 Apr 2026 13:06:41 +0200 Subject: [PATCH 18/18] correct keyboard events filtered and taking into account 0 Co-authored-by: Copilot --- packages/lib/src/time-input/TimeInput.tsx | 6 +++--- packages/lib/src/time-input/utils.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index ba2e44508..6435d6d83 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -89,9 +89,9 @@ const DxcTimeInput = forwardRef( const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; if (numberPart) { const [hour, minute, second] = numberPart.split(":").map(Number); - setHourValue(hour ? hour : undefined); - setMinuteValue(minute ? minute : undefined); - setSecondValue(second ? second : undefined); + setHourValue(hour || hour === 0 ? hour : undefined); + setMinuteValue(minute || minute === 0 ? minute : undefined); + setSecondValue(second || second === 0 ? second : undefined); } if (timeFormat === "12" && time.includes(" ")) { const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index bdd54c26a..1fcac0d63 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -87,7 +87,7 @@ export const handleKeyDown = ( } else { newValue = resolveValue(innerValue - 1, maxValue, minValue); } - } else if (isDayPeriod && /[apAP01]/.test(event.key)) { + } else if (isDayPeriod && /^[apAP01]$/.test(event.key)) { // AM/PM input const isAM = /[aA0]/.test(event.key); newValue = isAM ? 0 : 1;