From 3193f0e13e308f9872ebdab091efa64a273626b3 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Mon, 20 Apr 2026 13:56:38 +0200 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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; From b7f01cbd9e0d3770e72799d80293f3d8f10f126f Mon Sep 17 00:00:00 2001 From: Jialecl Date: Wed, 29 Apr 2026 10:04:47 +0200 Subject: [PATCH 19/29] Improved keyboard behavior and moved a function to utils Co-authored-by: Copilot --- .../time-input/code/examples/controlled.tsx | 2 +- .../time-input/code/examples/format.tsx | 2 +- .../time-input/code/examples/uncontrolled.tsx | 2 +- .../time-input/code/examples/withSeconds.tsx | 2 +- .../lib/src/time-input/TimeInput.test.tsx | 29 ++++++++++ packages/lib/src/time-input/TimePicker.tsx | 45 +-------------- packages/lib/src/time-input/utils.ts | 56 ++++++++++++++++++- 7 files changed, 88 insertions(+), 50 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 a9d69363c..a9156be49 100644 --- a/apps/website/screens/components/time-input/code/examples/controlled.tsx +++ b/apps/website/screens/components/time-input/code/examples/controlled.tsx @@ -7,7 +7,7 @@ const code = `() => { 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 02d1960a2..f224d88e2 100644 --- a/apps/website/screens/components/time-input/code/examples/format.tsx +++ b/apps/website/screens/components/time-input/code/examples/format.tsx @@ -9,7 +9,7 @@ const code = `() => { return ( 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 9f00e075a..5157fb5ca 100644 --- a/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx +++ b/apps/website/screens/components/time-input/code/examples/uncontrolled.tsx @@ -9,7 +9,7 @@ const code = `() => { return ( 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 f99a63101..63b4863f0 100644 --- a/apps/website/screens/components/time-input/code/examples/withSeconds.tsx +++ b/apps/website/screens/components/time-input/code/examples/withSeconds.tsx @@ -9,7 +9,7 @@ const code = `() => { return ( { userEvent.tab(); expect(buttons[1]).toHaveFocus(); }); + + it("Mixing keyboard inputs", () => { + 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("1"); + userEvent.keyboard("2"); + expect(mockOnChange).toHaveBeenCalledWith("12:30:00 AM"); + expect(inputs[1]).toHaveFocus(); + userEvent.keyboard("{ArrowUp}"); + userEvent.keyboard("{5}"); + expect(mockOnChange).toHaveBeenCalledWith("12:05:00 AM"); + }); }); diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 11c888e96..3772fbb8b 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 { useEffect, useState } from "react"; import TimePickerColumn from "./TimePickerColumn"; +import { handleColumnKeyDown } from "./utils"; // Array to be used in seconds and minutes. const STEP = 5; @@ -12,48 +13,6 @@ const TimePickerContainer = styled.div` height: 200px; gap: var(--spacing-gap-m); `; -const handleColumnKeyDown = ( - event: React.KeyboardEvent, - column: string, - focusedValue: number, - totalValues: number, - setValueToFocus: React.Dispatch>, - 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 + stepValue > totalValues ? stepValue : focusedValue + stepValue; - setValueToFocus((prev) => (prev === undefined ? 1 : newValue)); - } else if (focusedValue === totalValues - stepValue) { - setValueToFocus(0); - } else { - 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 - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; - setValueToFocus((prev) => (prev === undefined ? totalValues - stepValue : newValue)); - } else if (focusedValue === 0) { - setValueToFocus(totalValues - stepValue); - } else { - const newValue = focusedValue - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; - setValueToFocus(newValue); - } - } else if (["Enter", " "].includes(event.key)) { - if (onSelect) { - onSelect(focusedValue); - } - } -}; const TimePicker = ({ onSelecthours, @@ -143,7 +102,7 @@ const TimePicker = ({ } }} onKeyboardEvent={(event: React.KeyboardEvent, value: number) => - handleColumnKeyDown(event, "minute", value, 60, setMinuteToFocus, onSelectMinutes, STEP) + handleColumnKeyDown(event, "second", value, 60, setSecondToFocus, onSelectSeconds, STEP) } /> )} diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 1fcac0d63..f62105bd7 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -54,7 +54,7 @@ export const handleKeyDown = ( } } - if (!["Tab", "Enter"].includes(event.key)) event.preventDefault(); + if (!["Tab"].includes(event.key)) event.preventDefault(); if (/^\d$/.test(event.key) && !isDayPeriod) { // Number input @@ -71,6 +71,7 @@ export const handleKeyDown = ( rawInput.current = newStringValue; } if (typeof onComplete === "function") { + rawInput.current = ""; onComplete(); } } @@ -87,10 +88,11 @@ export const handleKeyDown = ( } else { newValue = resolveValue(innerValue - 1, maxValue, minValue); } - } else if (isDayPeriod && /^[apAP01]$/.test(event.key)) { + } else if (isDayPeriod && /^[apAP]$/.test(event.key)) { // AM/PM input - const isAM = /[aA0]/.test(event.key); + const isAM = /[aA]/.test(event.key); newValue = isAM ? 0 : 1; + rawInput.current = newValue.toString(); } setInnerValue((prevValue) => { return prevValue !== newValue ? newValue : prevValue; @@ -99,10 +101,15 @@ export const handleKeyDown = ( onChange(newValue); } if (event.key === "ArrowRight" && typeof onNext === "function") { + rawInput.current = ""; onNext(); } else if (event.key === "ArrowLeft" && typeof onPrevious === "function") { + rawInput.current = ""; onPrevious(); } + if (event.key === "Tab") { + rawInput.current = ""; + } }; export const generateEventValue = ( @@ -120,3 +127,46 @@ export const generateEventValue = ( timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : undefined}` : "" }`; }; + +export const handleColumnKeyDown = ( + event: React.KeyboardEvent, + column: string, + focusedValue: number, + totalValues: number, + setValueToFocus: React.Dispatch>, + 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 + stepValue > totalValues ? stepValue : focusedValue + stepValue; + setValueToFocus((prev) => (prev === undefined ? 1 : newValue)); + } else if (focusedValue === totalValues - stepValue) { + setValueToFocus(0); + } else { + 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 - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; + setValueToFocus((prev) => (prev === undefined ? totalValues - stepValue : newValue)); + } else if (focusedValue === 0) { + setValueToFocus(totalValues - stepValue); + } else { + const newValue = focusedValue - stepValue < 0 ? totalValues - stepValue : focusedValue - stepValue; + setValueToFocus(newValue); + } + } else if (["Enter", " "].includes(event.key)) { + if (onSelect) { + onSelect(focusedValue); + } + } +}; From a1380f8550babbf64cccbbb36aa4639ca4172521 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Wed, 29 Apr 2026 10:28:48 +0200 Subject: [PATCH 20/29] Removed calculateWidth from time Co-authored-by: Copilot --- packages/lib/src/time-input/TimeInput.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 6435d6d83..faa600039 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -1,6 +1,5 @@ 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"; @@ -14,6 +13,13 @@ import TimePicker from "./TimePicker"; import { generateEventValue } from "./utils"; import ErrorMessage from "../styles/forms/ErrorMessage"; +const sizes = { + small: "240px", + medium: "360px", + large: "480px", + fillParent: "100%", +}; + const TimeInputContainer = styled.div<{ size: TimeInputPropsType["size"]; }>` @@ -24,7 +30,7 @@ const TimeInputContainer = styled.div<{ font-size: var(--typography-label-m); font-weight: var(--typography-label-regular); color: var(--color-fg-neutral-dark); - width: ${({ size }) => calculateWidth(undefined, size)}; + width: ${({ size }) => (size ? sizes[size] : sizes.medium)}; `; const TimeInputField = styled.div<{ From ca00a869276f647f653f590407bfc9cc6a6c64e2 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Thu, 30 Apr 2026 09:46:55 +0200 Subject: [PATCH 21/29] Changes based on feedback related to styles and documentation Co-authored-by: Copilot --- .../time-input/code/TimeInputCodePage.tsx | 5 +- .../time-input/code/examples/format.tsx | 1 + .../overview/TimeInputOverviewPage.tsx | 16 +++-- .../overview/images/time_picker_action.png | Bin 0 -> 11912 bytes .../lib/src/time-input/TimeInput.stories.tsx | 2 + packages/lib/src/time-input/TimeInput.tsx | 67 +++++++++--------- .../lib/src/time-input/TimePickerColumn.tsx | 6 +- .../lib/src/time-input/TimeSpinButton.tsx | 16 +++-- 8 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 apps/website/screens/components/time-input/overview/images/time_picker_action.png diff --git a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx index 32dd70c69..20ce79119 100644 --- a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -27,7 +27,10 @@ const sections = [ string - Specifies a string to be used as the name for the timeInput element when no `label` is provided. + + Specifies a string to be used as the name for the timeInput element when no label is + provided. + 'Text input' 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 f224d88e2..3bf8b9a68 100644 --- a/apps/website/screens/components/time-input/code/examples/format.tsx +++ b/apps/website/screens/components/time-input/code/examples/format.tsx @@ -12,6 +12,7 @@ const code = `() => { label="Enter your time" defaultValue="20:00" timeFormat="24" + onChange={onChange} /> ); diff --git a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx index 903174b6b..798a28954 100644 --- a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -4,7 +4,8 @@ import DocFooter from "@/common/DocFooter"; 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"; +import timeInputPickerPopup from "./images/time_picker_popup.png"; +import timeInputPickerAction from "./images/time_picker_action.png"; const sections = [ { @@ -156,14 +157,19 @@ const sections = [ 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 + The component features a built-in time picker popup that can be opened via the time icon. + +
+ States for the time picker action +
+ + This popup 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. The 24-hour variant does not include the AM/PM selection.
- States for the time picker popup + States for the time picker popup
), diff --git a/apps/website/screens/components/time-input/overview/images/time_picker_action.png b/apps/website/screens/components/time-input/overview/images/time_picker_action.png new file mode 100644 index 0000000000000000000000000000000000000000..f46ffe3d3fec398f3c10064677b55238392f920c GIT binary patch literal 11912 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 literal 0 HcmV?d00001 diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index 346498d26..a01e8bb1d 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -68,7 +68,9 @@ const TimeInput = () => { />
+ + inputStylesByState(disabled, error, readOnly)} `; const ColonContainer = styled.span` padding: 0; - color: var(--color-fg-neutral-strong); + color: var(--color-fg-neutral-dark); `; const DxcTimeInput = forwardRef( @@ -173,8 +174,8 @@ const DxcTimeInput = forwardRef( )} - - + + ( /> )} + {timeFormat === "12" && ( + { + 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} + /> + )} - {timeFormat === "12" && ( - { - 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} - /> - )} {clearable && ( diff --git a/packages/lib/src/time-input/TimePickerColumn.tsx b/packages/lib/src/time-input/TimePickerColumn.tsx index d17f4e805..20fa97a9b 100644 --- a/packages/lib/src/time-input/TimePickerColumn.tsx +++ b/packages/lib/src/time-input/TimePickerColumn.tsx @@ -70,7 +70,11 @@ const TimePickerColumn = ({ onKeyboardEvent: (event: React.KeyboardEvent, value: number) => void; }) => { return ( - + {valuesArray.map((optionValue) => ( ` caret-color: transparent; color: ${(props) => - props.isPlaceholder + props.disabled ? "var(--color-fg-neutral-medium)" - : props.disabled - ? "var(--color-fg-neutral-medium)" + : props.isPlaceholder + ? "var(--color-fg-neutral-strong)" : "var(--color-fg-neutral-dark)"}; &:focus { ${(props) => !props.disabled && - `background-color: var(--color-bg-primary-lighter); + `background-color: var(--color-bg-primary-light); outline: none;`} } + height: var(--height-s); + min-width: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-padding-none) var(--spacing-padding-xxxs); + border-radius: var(--border-radius-s); + box-sizing: border-box; `; const generateDisplayValue = ( From 82d2f76a98a9678f68a424809819ce576b684397 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Thu, 30 Apr 2026 10:39:44 +0200 Subject: [PATCH 22/29] Additional changes --- .../overview/TimeInputOverviewPage.tsx | 5 +- packages/lib/src/time-input/TimeInput.tsx | 60 +++++++++---------- .../lib/src/time-input/TimeSpinButton.tsx | 2 +- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx index 798a28954..755c09ce2 100644 --- a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -190,8 +190,7 @@ const sections = [ user error. - Display time formats clearly and consistently across your application, especially if users from multiple - locales are expected. + Display time formats clearly and consistently across your application. Include a clear label that describes the context or purpose of the time (e.g., "Notification time" or @@ -237,7 +236,7 @@ const sections = [ content: ( - Include the time picker to reduce formatting errors and speed up time selection, especially for less + Utilize the time picker to reduce formatting errors and speed up time selection, especially for less tech-savvy users or on mobile. diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index fd8458670..3dbf0f685 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -292,38 +292,36 @@ const DxcTimeInput = forwardRef( /> )} - {timeFormat === "12" && ( - { - 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} - /> - )} + {timeFormat === "12" && ( + { + 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} + /> + )} {clearable && ( diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index 2303071ec..b2f783d1b 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -14,7 +14,7 @@ const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean; disabled: &:focus { ${(props) => !props.disabled && - `background-color: var(--color-bg-primary-light); + `background-color: var(--color-bg-alpha-light); outline: none;`} } height: var(--height-s); From c2ccbb760ad805d1892e622b1c15b20f66751bb6 Mon Sep 17 00:00:00 2001 From: Jialecl Date: Thu, 30 Apr 2026 12:09:26 +0200 Subject: [PATCH 23/29] More changes based on review comments. - Refactor TimeInput to remove isControlled ref and instead directly check if value is undefined. - Add timeFormat to useEffect dependencies to ensure day period updates correctly when time format changes. - Changed undefined values in events to empty strings. --- .../overview/TimeInputOverviewPage.tsx | 2 +- .../lib/src/time-input/TimeInput.stories.tsx | 30 +++++++------- .../lib/src/time-input/TimeInput.test.tsx | 16 ++++---- packages/lib/src/time-input/TimeInput.tsx | 41 +++++++++++-------- packages/lib/src/time-input/TimePicker.tsx | 22 +++++----- packages/lib/src/time-input/types.ts | 2 +- packages/lib/src/time-input/utils.ts | 4 +- 7 files changed, 61 insertions(+), 56 deletions(-) diff --git a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx index 755c09ce2..f3785db80 100644 --- a/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -13,7 +13,7 @@ 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 in working with the date input component - from booking systems + 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. diff --git a/packages/lib/src/time-input/TimeInput.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx index a01e8bb1d..6a295aa2c 100644 --- a/packages/lib/src/time-input/TimeInput.stories.tsx +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -119,10 +119,10 @@ const TimePickerExamples = () => { <DxcContainer width="250px"> - <TimePicker onSelectMinutes={() => {}} onSelecthours={() => {}} timeFormat="24" id="testId" tabIndex={0} /> + <TimePicker onSelectMinutes={() => {}} onSelectHours={() => {}} timeFormat="24" id="testId" tabIndex={0} /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="24" id="testId" tabIndex={0} @@ -131,7 +131,7 @@ const TimePickerExamples = () => { /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="24" id="testId" tabIndex={0} @@ -145,10 +145,10 @@ const TimePickerExamples = () => { <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} /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -158,7 +158,7 @@ const TimePickerExamples = () => { /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -173,10 +173,10 @@ const TimePickerExamples = () => { <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} /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -186,7 +186,7 @@ const TimePickerExamples = () => { /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -201,10 +201,10 @@ const TimePickerExamples = () => { <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} /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -214,7 +214,7 @@ const TimePickerExamples = () => { /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -229,10 +229,10 @@ const TimePickerExamples = () => { <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} /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} @@ -242,7 +242,7 @@ const TimePickerExamples = () => { /> <TimePicker onSelectMinutes={() => {}} - onSelecthours={() => {}} + onSelectHours={() => {}} timeFormat="12" id="testId" tabIndex={0} diff --git a/packages/lib/src/time-input/TimeInput.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx index ff092db55..64c3569f5 100644 --- a/packages/lib/src/time-input/TimeInput.test.tsx +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -14,8 +14,6 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ beforeEach(() => jest.clearAllMocks()); -beforeEach(() => jest.clearAllMocks()); - describe("DxcTimeInput rendering", () => { it("renders label", () => { const { getByText } = render(<DxcTimeInput label="Time input" helperText="Pick a time" />); @@ -90,11 +88,11 @@ describe("DxcTimeInput rendering", () => { userEvent.tab(); expect(inputs[0]).toHaveFocus(); userEvent.keyboard("{ArrowUp}"); - expect(mockOnChange).toHaveBeenCalledWith("01:undefined undefined"); + expect(mockOnChange).toHaveBeenCalledWith("01: "); userEvent.tab(); expect(inputs[1]).toHaveFocus(); userEvent.keyboard("{ArrowDown}"); - expect(mockOnChange).toHaveBeenCalledWith("01:59 undefined"); + expect(mockOnChange).toHaveBeenCalledWith("01:59 "); userEvent.tab(); expect(inputs[2]).toHaveFocus(); userEvent.keyboard("{A}"); @@ -112,10 +110,10 @@ describe("DxcTimeInput rendering", () => { expect(getByText("AM")).toBeTruthy(); const hourbutton = getAllByText("07"); if (hourbutton[0]) userEvent.click(hourbutton[0]); - expect(mockOnChange).toHaveBeenCalledWith("07:undefined undefined"); + expect(mockOnChange).toHaveBeenCalledWith("07: "); const minuteButton = getAllByText("30"); if (minuteButton[0]) userEvent.click(minuteButton[0]); - expect(mockOnChange).toHaveBeenCalledWith("07:30 undefined"); + expect(mockOnChange).toHaveBeenCalledWith("07:30 "); const amButton = getByText("AM"); expect(amButton).toBeTruthy(); userEvent.click(amButton); @@ -132,14 +130,14 @@ describe("DxcTimeInput rendering", () => { userEvent.keyboard("{ArrowDown}"); userEvent.keyboard("{ArrowDown}"); userEvent.keyboard("{Enter}"); - expect(mockOnChange).toHaveBeenCalledWith("03:undefined undefined"); + expect(mockOnChange).toHaveBeenCalledWith("03: "); userEvent.tab(); userEvent.keyboard("{ArrowUp}"); userEvent.keyboard("{Enter}"); - expect(mockOnChange).toHaveBeenCalledWith("03:55 undefined"); + expect(mockOnChange).toHaveBeenCalledWith("03:55 "); userEvent.keyboard("{ArrowDown}"); userEvent.keyboard(" "); - expect(mockOnChange).toHaveBeenCalledWith("03:00 undefined"); + expect(mockOnChange).toHaveBeenCalledWith("03:00 "); userEvent.tab(); userEvent.keyboard("{ArrowDown}"); userEvent.keyboard("{Enter}"); diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 3dbf0f685..3624c1a6c 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -56,7 +56,7 @@ const ColonContainer = styled.span` const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( ( { - ariaLabel = "Text input", + ariaLabel = "Time input", clearable = false, defaultValue = "", disabled = false, @@ -87,7 +87,7 @@ 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 = value !== undefined; const translatedLabels = useContext(HalstackLanguageContext); useEffect(() => { @@ -103,9 +103,16 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( if (timeFormat === "12" && time.includes(" ")) { const dayPeriodValue = time.split(" ")[1] === "AM" ? 0 : time.split(" ")[1] === "PM" ? 1 : undefined; setDayPeriodValue(dayPeriodValue); + } else { + setDayPeriodValue(undefined); } + } else { + setHourValue(undefined); + setMinuteValue(undefined); + setSecondValue(undefined); + setDayPeriodValue(undefined); } - }, [value, defaultValue]); + }, [value, defaultValue, timeFormat]); const generatedInputValue = () => { if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { @@ -116,7 +123,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( }; const handleClearActionOnClick = () => { - if (!isControlled.current) { + if (!isControlled) { setHourValue(undefined); setMinuteValue(undefined); setSecondValue(undefined); @@ -185,14 +192,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dataType="hour" readOnly={readOnly} disabled={disabled} - isControlled={isControlled.current} + isControlled={isControlled} onComplete={() => { if (minuteRef.current) { minuteRef.current.focus(); } }} onChange={(value) => { - if (!isControlled.current) { + if (!isControlled) { setHourValue(value); } if (typeof onChange === "function") { @@ -218,7 +225,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dataType="minute" readOnly={readOnly} disabled={disabled} - isControlled={isControlled.current} + isControlled={isControlled} onComplete={() => { if (showSeconds && secondRef.current) { secondRef.current.focus(); @@ -227,7 +234,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }} onChange={(value) => { - if (!isControlled.current) { + if (!isControlled) { setMinuteValue(value); } if (typeof onChange === "function") { @@ -262,14 +269,14 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dataType="second" readOnly={readOnly} disabled={disabled} - isControlled={isControlled.current} + isControlled={isControlled} onComplete={() => { if (timeFormat === "12" && dayPeriodRef.current) { dayPeriodRef.current.focus(); } }} onChange={(value) => { - if (!isControlled.current) { + if (!isControlled) { setSecondValue(value); } if (typeof onChange === "function") { @@ -303,9 +310,9 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( dataType="dayPeriod" readOnly={readOnly} disabled={disabled} - isControlled={isControlled.current} + isControlled={isControlled} onChange={(value) => { - if (!isControlled.current) { + if (!isControlled) { setDayPeriodValue(value); } if (typeof onChange === "function") { @@ -336,8 +343,8 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( <DxcPopover popoverContent={ <TimePicker - onSelecthours={(value) => { - if (!isControlled.current) { + onSelectHours={(value) => { + if (!isControlled) { setHourValue(value); } if (typeof onChange === "function") { @@ -347,7 +354,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }} onSelectMinutes={(value) => { - if (!isControlled.current) { + if (!isControlled) { setMinuteValue(value); } if (typeof onChange === "function") { @@ -357,7 +364,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }} onSelectSeconds={(value) => { - if (!isControlled.current) { + if (!isControlled) { setSecondValue(value); } if (typeof onChange === "function") { @@ -367,7 +374,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( } }} onSelectDayPeriod={(value: number) => { - if (!isControlled.current) { + if (!isControlled) { setDayPeriodValue(value); } if (typeof onChange === "function") { diff --git a/packages/lib/src/time-input/TimePicker.tsx b/packages/lib/src/time-input/TimePicker.tsx index 3772fbb8b..07f5f7fcf 100644 --- a/packages/lib/src/time-input/TimePicker.tsx +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -15,7 +15,7 @@ const TimePickerContainer = styled.div` `; const TimePicker = ({ - onSelecthours, + onSelectHours, onSelectMinutes, onSelectSeconds, onSelectDayPeriod, @@ -35,25 +35,25 @@ const TimePicker = ({ const totalHours = timeFormat === "12" ? 12 : 24; useEffect(() => { - if (dayPeriodToFocus !== undefined) { + if (dayPeriodToFocus !== undefined && id) { document.getElementById(`${id}-dayPeriod-${dayPeriodToFocus}`)?.focus(); } - }, [dayPeriodToFocus]); + }, [dayPeriodToFocus, id]); useEffect(() => { - if (secondToFocus !== undefined) { + if (secondToFocus !== undefined && id) { document.getElementById(`${id}-second-${secondToFocus}`)?.focus(); } - }, [secondToFocus]); + }, [secondToFocus, id]); useEffect(() => { - if (minuteToFocus !== undefined) { + if (minuteToFocus !== undefined && id) { document.getElementById(`${id}-minute-${minuteToFocus}`)?.focus(); } - }, [minuteToFocus]); + }, [minuteToFocus, id]); useEffect(() => { - if (hourToFocus !== undefined) { + if (hourToFocus !== undefined && id) { document.getElementById(`${id}-hour-${hourToFocus}`)?.focus(); } - }, [hourToFocus]); + }, [hourToFocus, id]); return ( <TimePickerContainer role="listbox" aria-label="Time picker"> @@ -65,11 +65,11 @@ const TimePicker = ({ tabIndex={tabIndex} dataType="hour" onClick={(value: number) => { - onSelecthours(value); + onSelectHours(value); setHourToFocus(value); }} onKeyboardEvent={(event: React.KeyboardEvent, value: number) => - handleColumnKeyDown(event, "hour", value, totalHours, setHourToFocus, onSelecthours) + handleColumnKeyDown(event, "hour", value, totalHours, setHourToFocus, onSelectHours) } /> <TimePickerColumn diff --git a/packages/lib/src/time-input/types.ts b/packages/lib/src/time-input/types.ts index cb74908fa..1ec59e7a8 100644 --- a/packages/lib/src/time-input/types.ts +++ b/packages/lib/src/time-input/types.ts @@ -104,7 +104,7 @@ export type TimeSpinButtonPropsType = { }; export type TimePickerPropsType = { - onSelecthours: (hours: number) => void; + onSelectHours: (hours: number) => void; onSelectMinutes: (minutes: number) => void; onSelectSeconds?: (seconds: number) => void; onSelectDayPeriod?: (isPM: number) => void; diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index f62105bd7..f3a9c7e88 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -1,4 +1,4 @@ -export const pad = (num?: number) => (num !== undefined && num < 10 ? `0${num}` : `${num}`); +export const pad = (num?: number) => (num !== undefined && num < 10 ? `0${num}` : num !== undefined ? `${num}` : ""); const resolveValue = (value: string | number, maxValue: number, minValue: number) => { const input = typeof value === "string" ? parseInt(value, 10) : value; @@ -124,7 +124,7 @@ export const generateEventValue = ( return ""; } return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ - timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : undefined}` : "" + timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : ""}` : "" }`; }; From a665103fda987e26587640a2d7e215efb9ee6866 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 12:41:58 +0200 Subject: [PATCH 24/29] Added missing localization options --- .../localization/LocalizationPage.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/website/screens/guidelines/localization/LocalizationPage.tsx b/apps/website/screens/guidelines/localization/LocalizationPage.tsx index c27d010f9..de648b101 100644 --- a/apps/website/screens/guidelines/localization/LocalizationPage.tsx +++ b/apps/website/screens/guidelines/localization/LocalizationPage.tsx @@ -220,6 +220,12 @@ const sections = [ </td> <td>Invalid date.</td> </tr> + <tr> + <td> + <Code>datePickerActionTitle</Code> + </td> + <td>Select date</td> + </tr> </tbody> </DxcTable> ), @@ -689,6 +695,48 @@ const sections = [ </DxcTable> ), }, + { + title: "timeInput", + content: ( + <DxcTable> + <thead> + <tr> + <th>Label Name</th> + <th>Default value</th> + </tr> + </thead> + <tbody> + <tr> + <td> + <Code>timePickerActionTitle</Code> + </td> + <td>Select time</td> + </tr> + </tbody> + </DxcTable> + ), + }, + { + title: "toast", + content: ( + <DxcTable> + <thead> + <tr> + <th>Label Name</th> + <th>Default value</th> + </tr> + </thead> + <tbody> + <tr> + <td> + <Code>clearToastActionTitle</Code> + </td> + <td>Clear toast</td> + </tr> + </tbody> + </DxcTable> + ), + }, ], }, ]; From 907303f9d2c9706e61f5ff92770abc9de95f4968 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 12:44:41 +0200 Subject: [PATCH 25/29] Added localization to date and time inputs --- packages/lib/src/common/variables.ts | 4 ++++ packages/lib/src/date-input/DateInput.tsx | 2 +- packages/lib/src/time-input/TimeInput.tsx | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts index b16adc089..399a3dd01 100644 --- a/packages/lib/src/common/variables.ts +++ b/packages/lib/src/common/variables.ts @@ -49,6 +49,7 @@ export const defaultTranslatedComponentLabels = { }, dateInput: { invalidDateErrorMessage: "Invalid date.", + datePickerActionTitle: "Select date", }, dialog: { closeIconAriaLabel: "Close dialog", @@ -126,6 +127,9 @@ export const defaultTranslatedComponentLabels = { searchingMessage: "Searching...", fetchingDataErrorMessage: "Error fetching data", }, + timeInput: { + timePickerActionTitle: "Select date", + }, toast: { clearToastActionTitle: "Clear toast", }, diff --git a/packages/lib/src/date-input/DateInput.tsx b/packages/lib/src/date-input/DateInput.tsx index d430eafc7..e05ee8cec 100644 --- a/packages/lib/src/date-input/DateInput.tsx +++ b/packages/lib/src/date-input/DateInput.tsx @@ -316,7 +316,7 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( action={{ onClick: openCalendar, icon: "filled_calendar_today", - title: "Select date", + title: !disabled ? translatedLabels.dateInput.datePickerActionTitle : undefined, }} clearable={clearable} disabled={disabled} diff --git a/packages/lib/src/time-input/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx index 3624c1a6c..9ca7969b7 100644 --- a/packages/lib/src/time-input/TimeInput.tsx +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -405,7 +405,7 @@ const DxcTimeInput = forwardRef<RefType, TimeInputPropsType>( size="xsmall" disabled={disabled} icon="schedule" - title="Select time" + title={!disabled ? translatedLabels.timeInput.timePickerActionTitle : undefined} onClick={() => setIsOpen(true)} /> </DxcPopover> From c635a804564e3fbcfe4fde292695e6b453af67f5 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 12:45:55 +0200 Subject: [PATCH 26/29] refactored function for clarity --- packages/lib/src/time-input/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index f3a9c7e88..372f5b0e3 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -1,4 +1,7 @@ -export const pad = (num?: number) => (num !== undefined && num < 10 ? `0${num}` : num !== undefined ? `${num}` : ""); +export const pad = (num?: number) => { + if (num === undefined) return ""; + return num < 10 ? `0${num}` : `${num}`; +}; const resolveValue = (value: string | number, maxValue: number, minValue: number) => { const input = typeof value === "string" ? parseInt(value, 10) : value; From 083e6fc6d02d2cffc33432b5f6fa956ad29bf4c2 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 13:19:45 +0200 Subject: [PATCH 27/29] Correct values applied to aria label and translations --- .../screens/components/time-input/code/TimeInputCodePage.tsx | 2 +- packages/lib/src/common/variables.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx index 20ce79119..2980b878c 100644 --- a/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -32,7 +32,7 @@ const sections = [ provided. </td> <td> - <TableCode>'Text input'</TableCode> + <TableCode>'Time input'</TableCode> </td> </tr> <tr> diff --git a/packages/lib/src/common/variables.ts b/packages/lib/src/common/variables.ts index 399a3dd01..5909365a2 100644 --- a/packages/lib/src/common/variables.ts +++ b/packages/lib/src/common/variables.ts @@ -128,7 +128,7 @@ export const defaultTranslatedComponentLabels = { fetchingDataErrorMessage: "Error fetching data", }, timeInput: { - timePickerActionTitle: "Select date", + timePickerActionTitle: "Select time", }, toast: { clearToastActionTitle: "Clear toast", From e7d3150a9cae10e3279c2f46152f308ce4c36090 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 16:44:51 +0200 Subject: [PATCH 28/29] Some minor improvements based on comments Co-authored-by: Copilot <copilot@github.com> --- packages/lib/src/time-input/TimeSpinButton.tsx | 14 ++++++++------ packages/lib/src/time-input/utils.ts | 6 +++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx index b2f783d1b..ccc1c2c53 100644 --- a/packages/lib/src/time-input/TimeSpinButton.tsx +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -1,7 +1,7 @@ import styled from "@emotion/styled"; import { TimeSpinButtonPropsType } from "./types"; import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; -import { handleKeyDown } from "./utils"; +import { handleKeyDown, pad, returnDayPeriod } from "./utils"; const TimeSpinButtonContainer = styled.span<{ isPlaceholder: boolean; disabled: boolean }>` caret-color: transparent; @@ -35,7 +35,7 @@ const generateDisplayValue = ( ) => { let displayValue; if (dataType === "dayPeriod") { - displayValue = value === 0 ? "AM" : value === 1 ? "PM" : placeholder; + displayValue = value != null ? returnDayPeriod(value) : placeholder; } else { displayValue = value != null ? value.toString().padStart(maxValue.toString().length, "0") : placeholder; } @@ -62,7 +62,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( ref ) => { const [innerValue, setInnerValue] = useState<number | undefined>(value); - let spanRef = useRef<HTMLSpanElement | null>(null); + const spanRef = useRef<HTMLSpanElement | null>(null); const placeholder = useMemo(() => { switch (dataType) { @@ -81,7 +81,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( default: return "--"; } - }, [dataType]); + }, [dataType, maxValue]); useEffect(() => { if (!spanRef.current) return; @@ -97,7 +97,7 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( if (spanRef.current) { spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); } - }, [value]); + }, [value, placeholder, maxValue, dataType]); // Values used to track the raw input before it's resolved to a valid value. const rawInput = useRef<string>(""); @@ -115,7 +115,9 @@ const TimeSpinButton = forwardRef<HTMLSpanElement, TimeSpinButtonPropsType>( }} role="spinbutton" aria-valuenow={innerValue ?? undefined} - aria-valuetext={innerValue != null ? String(innerValue) : "Empty"} + aria-valuetext={ + innerValue != null ? (dataType === "dayPeriod" ? returnDayPeriod(innerValue) : pad(innerValue)) : "Empty" + } aria-valuemin={minValue} aria-valuemax={maxValue} aria-label={ariaLabel} diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 372f5b0e3..7ad7d2e73 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -3,6 +3,10 @@ export const pad = (num?: number) => { return num < 10 ? `0${num}` : `${num}`; }; +export const returnDayPeriod = (value?: number) => { + return value === 0 ? "AM" : value === 1 ? "PM" : ""; +}; + const resolveValue = (value: string | number, maxValue: number, minValue: number) => { const input = typeof value === "string" ? parseInt(value, 10) : value; if (input > maxValue) { @@ -127,7 +131,7 @@ export const generateEventValue = ( return ""; } return `${pad(hour)}:${pad(minute)}${showSeconds ? `:${pad(second)}` : ""}${ - timeFormat === "12" ? ` ${dayPeriod !== undefined ? (dayPeriod === 0 ? "AM" : "PM") : ""}` : "" + timeFormat === "12" ? ` ${returnDayPeriod(dayPeriod)}` : "" }`; }; From 8d48b9e06c30996a3a677c90317c643b3823edc8 Mon Sep 17 00:00:00 2001 From: Jialecl <jialestrabajos@gmail.com> Date: Thu, 30 Apr 2026 17:16:55 +0200 Subject: [PATCH 29/29] Fixing empty values in spinButton Co-authored-by: Copilot <copilot@github.com> --- packages/lib/src/time-input/utils.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/lib/src/time-input/utils.ts b/packages/lib/src/time-input/utils.ts index 7ad7d2e73..7a23e110f 100644 --- a/packages/lib/src/time-input/utils.ts +++ b/packages/lib/src/time-input/utils.ts @@ -69,20 +69,16 @@ export const handleKeyDown = ( rawInput.current = (rawInput.current + newDigit.current).slice(-maxValue.toString().length); 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. + console.log("rawInput:", rawInput.current, "newDigit:", newDigit.current, "newValue:", newValue); 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; - } + rawInput.current = pad(newValue); if (typeof onComplete === "function") { - rawInput.current = ""; onComplete(); + rawInput.current = ""; } + } else { + input.textContent = rawInput.current; } - input.textContent = rawInput.current; } else if (event.key === "ArrowUp") { if (innerValue == null || innerValue >= maxValue) { newValue = minValue;