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 @@ /// /// -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 = () => ( + <> + + 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..32dd70c69 --- /dev/null +++ b/apps/website/screens/components/time-input/code/TimeInputCodePage.tsx @@ -0,0 +1,248 @@ +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"; +import uncontrolled from "./examples/uncontrolled"; +import format from "./examples/format"; +import withSeconds from "./examples/withSeconds"; + +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: "24h format", + content: , + }, + { + title: "With seconds", + 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..a9d69363c --- /dev/null +++ b/apps/website/screens/components/time-input/code/examples/controlled.tsx @@ -0,0 +1,24 @@ +import { DxcTimeInput, DxcInset } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +const code = `() => { + const [value, setValue] = useState(""); + + return ( + + setValue(newValue)} + /> + + ); +}`; + +const scope = { + DxcTimeInput, + DxcInset, + useState, +}; + +export default { code, scope }; 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..02d1960a2 --- /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..9f00e075a --- /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..f99a63101 --- /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/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..903174b6b --- /dev/null +++ b/apps/website/screens/components/time-input/overview/TimeInputOverviewPage.tsx @@ -0,0 +1,269 @@ +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 anatomy from "./images/time_input_anatomy.png"; +import timeInputTimePickerPopup from "./images/time_picker_popup.png"; + +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 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. + + ), + }, + { + 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. + + + 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. + + + 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. + + + ), + }, + { + 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. The 24-hour variant does not include the AM/PM selection. + +
+ 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/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 000000000..23e4449e0 Binary files /dev/null and b/apps/website/screens/components/time-input/overview/images/time_input_anatomy.png differ diff --git a/apps/website/screens/components/time-input/overview/images/time_picker_popup.png b/apps/website/screens/components/time-input/overview/images/time_picker_popup.png new file mode 100644 index 000000000..a65c2f72e Binary files /dev/null and b/apps/website/screens/components/time-input/overview/images/time_picker_popup.png differ 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.stories.tsx b/packages/lib/src/time-input/TimeInput.stories.tsx new file mode 100644 index 000000000..346498d26 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.stories.tsx @@ -0,0 +1,274 @@ +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 DxcContainer from "../container/Container"; +import { userEvent, within } from "storybook/internal/test"; + +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 = () => { + const continentalValue = "18:30:20"; + const value = "6:30:20 AM"; + const TimeInputExamples = () => { + return ( + <> + { + console.log(`Value changed: ${val}`); + }} + onBlur={(val) => { + console.log(val); + }} + clearable + /> + + { + console.log(`Value changed: ${val}`); + }} + onBlur={(val) => { + console.log(val); + }} + /> + + + { + console.log(`Value changed: ${val}`); + }} + size="fillParent" + /> + + + + + + + ); + }; + return ( + <> + + + <TimeInputExamples /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-hover"}> + <Title title="Hover" theme="light" level={2} /> + <TimeInputExamples /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-focus"}> + <Title title="Focus" theme="light" level={2} /> + <TimeInputExamples /> + </ExampleContainer> + <ExampleContainer pseudoState={"pseudo-active"}> + <Title title="Active" theme="light" level={2} /> + <TimeInputExamples /> + </ExampleContainer> + </> + ); +}; + +const TimePickerExamples = () => { + return ( + <> + <ExampleContainer expanded> + <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} /> + <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.test.tsx b/packages/lib/src/time-input/TimeInput.test.tsx new file mode 100644 index 000000000..6897c3fa7 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.test.tsx @@ -0,0 +1,241 @@ +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; +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 correctly selected", () => { + const mockOnChange = jest.fn(); + const { getByRole, getAllByRole } = render(<DxcTimeInput value="05:30 AM" onChange={mockOnChange} />); + 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("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("option", { name: "10" }).find((hourButton) => hourButton.id.includes("hour")); + if (newHourButton) userEvent.click(newHourButton); + expect(mockOnChange).toHaveBeenCalledWith("10:30 AM"); + }); + + 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 click 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"); + }); + + it("TimePicker keyboard interaction", () => { + const mockOnChange = jest.fn(); + const { getByRole, getByText } = render(<DxcTimeInput label="Time input" onChange={mockOnChange} />); + 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("{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:00 PM"); + }); + + it("TimeInput correctly move focus when each spinbutton is completed", () => { + const mockOnChange = jest.fn(); + const { getAllByRole, getByText } = render( + <DxcTimeInput + label="Time input" + timeFormat="24" + clearable + defaultValue="23:30:00" + showSeconds + onChange={mockOnChange} + /> + ); + 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( + <DxcTimeInput + label="Time input" + timeFormat="12" + clearable + defaultValue="10:30:00" + showSeconds + onChange={mockOnChange} + /> + ); + 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/TimeInput.tsx b/packages/lib/src/time-input/TimeInput.tsx new file mode 100644 index 000000000..6435d6d83 --- /dev/null +++ b/packages/lib/src/time-input/TimeInput.tsx @@ -0,0 +1,414 @@ +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 TimePicker from "./TimePicker"; +import { generateEventValue } from "./utils"; +import ErrorMessage from "../styles/forms/ErrorMessage"; + +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 errorId = `error-${useId()}`; + const [hourValue, setHourValue] = useState<number | undefined>(undefined); + const [minuteValue, setMinuteValue] = useState<number | undefined>(undefined); + const [secondValue, setSecondValue] = 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); + const secondRef = useRef<HTMLSpanElement>(null); + const dayPeriodRef = useRef<HTMLSpanElement>(null); + const isControlled = useRef(value !== undefined); + const translatedLabels = useContext(HalstackLanguageContext); + + useEffect(() => { + const time = value || defaultValue || undefined; + if (time) { + const numberPart = timeFormat === "12" ? time.split(" ")[0] : time; + if (numberPart) { + const [hour, minute, second] = numberPart.split(":").map(Number); + 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; + setDayPeriodValue(dayPeriodValue); + } + } + }, [value, defaultValue]); + + const generatedInputValue = () => { + if (hourValue === undefined && minuteValue === undefined && secondValue === undefined) { + return ""; + } else { + return generateEventValue(hourValue, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat); + } + }; + + const handleClearActionOnClick = () => { + if (!isControlled.current) { + setHourValue(undefined); + setMinuteValue(undefined); + setSecondValue(undefined); + setDayPeriodValue(undefined); + } + if (typeof onChange === "function") { + 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"; + } + if ( + !optional && + (hourValue === undefined || + minuteValue === undefined || + (showSeconds && secondValue === undefined) || + (timeFormat === "12" && dayPeriodValue === undefined)) + ) { + return "This field is required"; + } + }; + + return ( + <> + <TimeInputContainer + size={size} + ref={ref} + onBlur={() => { + if (typeof onBlur === "function") { + onBlur({ + value: generatedInputValue(), + error: validateTimeValue(generatedInputValue()), + }); + } + }} + onChange={() => { + if (typeof onChange === "function") { + onChange(generatedInputValue()); + } + }} + > + <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 + 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(value, minuteValue, secondValue, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onSelectMinutes={(value) => { + if (!isControlled.current) { + setMinuteValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, value, secondValue, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onSelectSeconds={(value) => { + if (!isControlled.current) { + setSecondValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, minuteValue, value, dayPeriodValue, showSeconds, timeFormat) + ); + } + }} + onSelectDayPeriod={(value: number) => { + if (!isControlled.current) { + setDayPeriodValue(value); + } + if (typeof onChange === "function") { + onChange( + generateEventValue(hourValue, minuteValue, secondValue, value, showSeconds, timeFormat) + ); + } + }} + timeFormat={timeFormat} + showSeconds={showSeconds} + hourValue={hourValue} + minuteValue={minuteValue} + secondValue={secondValue} + dayPeriod={dayPeriodValue} + id={inputId} + tabIndex={tabIndex} + /> + } + isOpen={isOpen} + offset={8} + onClose={() => { + setIsOpen(false); + }} + align="end" + asChild + > + <DxcActionIcon + size="xsmall" + disabled={disabled} + icon="schedule" + title="Select time" + onClick={() => setIsOpen(true)} + /> + </DxcPopover> + </DxcFlex> + </TimeInputField> + </TimeInputContainer> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} + <input + aria-label={label ?? ariaLabel} + aria-errormessage={error ? errorId : undefined} + type="hidden" + name={name} + value={generatedInputValue()} + /> + </> + ); + } +); + +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..11c888e96 --- /dev/null +++ b/packages/lib/src/time-input/TimePicker.tsx @@ -0,0 +1,173 @@ +import styled from "@emotion/styled"; +import { TimePickerPropsType } from "./types"; +import { useEffect, useState } from "react"; +import TimePickerColumn from "./TimePickerColumn"; + +// 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; + gap: var(--spacing-gap-m); +`; +const handleColumnKeyDown = ( + event: React.KeyboardEvent, + column: string, + focusedValue: number, + totalValues: number, + setValueToFocus: React.Dispatch<React.SetStateAction<number>>, + 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, + onSelectMinutes, + onSelectSeconds, + onSelectDayPeriod, + timeFormat, + showSeconds, + hourValue, + minuteValue, + secondValue, + dayPeriod, + id, + tabIndex = 0, +}: TimePickerPropsType) => { + 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 (dayPeriodToFocus !== undefined) { + document.getElementById(`${id}-dayPeriod-${dayPeriodToFocus}`)?.focus(); + } + }, [dayPeriodToFocus]); + useEffect(() => { + if (secondToFocus !== undefined) { + document.getElementById(`${id}-second-${secondToFocus}`)?.focus(); + } + }, [secondToFocus]); + useEffect(() => { + if (minuteToFocus !== undefined) { + document.getElementById(`${id}-minute-${minuteToFocus}`)?.focus(); + } + }, [minuteToFocus]); + useEffect(() => { + if (hourToFocus !== undefined) { + document.getElementById(`${id}-hour-${hourToFocus}`)?.focus(); + } + }, [hourToFocus]); + + return ( + <TimePickerContainer role="listbox" aria-label="Time picker"> + <TimePickerColumn + valuesArray={Array.from({ length: totalHours }, (_, 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) + } + /> + <TimePickerColumn + valuesArray={ARRAY_OF_60} + id={id} + selectedValue={minuteValue} + valueToFocus={minuteToFocus} + tabIndex={tabIndex} + dataType="minute" + onClick={(value: number) => { + onSelectMinutes(value); + setMinuteToFocus(value); + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "minute", value, 60, setMinuteToFocus, onSelectMinutes, STEP) + } + /> + {showSeconds && ( + <TimePickerColumn + valuesArray={ARRAY_OF_60} + id={id} + selectedValue={secondValue} + valueToFocus={secondToFocus} + tabIndex={tabIndex} + dataType="second" + onClick={(value: number) => { + if (typeof onSelectSeconds === "function") { + onSelectSeconds(value); + setSecondToFocus(value); + } + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "minute", value, 60, setMinuteToFocus, onSelectMinutes, STEP) + } + /> + )} + {timeFormat === "12" && ( + <TimePickerColumn + valuesArray={[0, 1]} + id={id} + selectedValue={dayPeriod} + valueToFocus={dayPeriodToFocus} + tabIndex={tabIndex} + dataType="dayPeriod" + onClick={(value: number) => { + if (typeof onSelectDayPeriod === "function") { + onSelectDayPeriod(value); + setDayPeriodToFocus(value); + } + }} + onKeyboardEvent={(event: React.KeyboardEvent, value: number) => + handleColumnKeyDown(event, "dayPeriod", value, 2, setDayPeriodToFocus, onSelectDayPeriod) + } + /> + )} + </TimePickerContainer> + ); +}; + +export default TimePicker; 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 ( + <DxcContainer maxHeight="100%" overflow="auto"> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)"> + {valuesArray.map((optionValue) => ( + <TimePickerOption + role="option" + key={`${dataType}-${returnHourBasedOnIndex(optionValue, dataType)}`} + id={`${id}-${dataType}-${returnHourBasedOnIndex(optionValue, dataType)}`} + selected={selectedValue === returnHourBasedOnIndex(optionValue, dataType)} + aria-selected={selectedValue === returnHourBasedOnIndex(optionValue, dataType)} + autoFocus={valueToFocus === returnHourBasedOnIndex(optionValue, dataType)} + tabIndex={valueToFocus === returnHourBasedOnIndex(optionValue, dataType) ? tabIndex || 0 : -1} + onClick={() => { + 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))} + </TimePickerOption> + ))} + </DxcFlex> + </DxcContainer> + ); +}; + +export default TimePickerColumn; diff --git a/packages/lib/src/time-input/TimeSpinButton.tsx b/packages/lib/src/time-input/TimeSpinButton.tsx new file mode 100644 index 000000000..f8e3c1f17 --- /dev/null +++ b/packages/lib/src/time-input/TimeSpinButton.tsx @@ -0,0 +1,144 @@ +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; disabled: boolean }>` + caret-color: transparent; + color: ${(props) => + props.isPlaceholder + ? "var(--color-fg-neutral-medium)" + : props.disabled + ? "var(--color-fg-neutral-medium)" + : "var(--color-fg-neutral-dark)"}; + &:focus { + ${(props) => + !props.disabled && + `background-color: var(--color-bg-primary-lighter); + outline: none;`} + } +`; + +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>( + ( + { + ariaLabel, + value, + minValue, + maxValue, + tabIndex, + dataType, + readOnly, + disabled, + isControlled, + 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": + if (maxValue === 12) { + return "hh"; + } else { + return "HH"; + } + case "minute": + return "mm"; + case "second": + return "ss"; + case "dayPeriod": + return "aa"; + default: + return "--"; + } + }, [dataType]); + + useEffect(() => { + if (!spanRef.current) return; + if (!isControlled) { + spanRef.current.textContent = generateDisplayValue(dataType, innerValue, placeholder, maxValue); + } else { + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); + } + }, [innerValue, placeholder, maxValue, dataType, isControlled]); + + useEffect(() => { + setInnerValue(value); + if (spanRef.current) { + spanRef.current.textContent = generateDisplayValue(dataType, value, placeholder, maxValue); + } + }, [value]); + + // Values used to track the raw input before it's resolved to a valid value. + const rawInput = useRef<string>(""); + const newDigit = useRef<string>(""); + + 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-label={ariaLabel} + disabled={disabled} + contentEditable={!readOnly && !disabled ? "plaintext-only" : "false"} + inputMode={dataType !== "dayPeriod" ? "numeric" : undefined} + tabIndex={tabIndex} + data-type={dataType} + data-placeholder={innerValue == null} + isPlaceholder={innerValue == null} + onKeyDown={(event) => + handleKeyDown( + event, + !readOnly && !disabled, + rawInput, + newDigit, + spanRef, + setInnerValue, + innerValue, + maxValue, + minValue, + dataType === "dayPeriod", + onChange, + onComplete, + onNext, + onPrevious + ) + } + /> + ); + } +); + +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..cb74908fa --- /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 timeInput element when no `label` is provided. + */ + ariaLabel?: 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. + */ + 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 + * 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 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. + */ + readOnly?: 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. + */ + 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 = { + ariaLabel?: string; + value: number | undefined; + minValue: number; + maxValue: number; + tabIndex: number; + dataType?: "hour" | "minute" | "second" | "dayPeriod"; + readOnly: boolean; + disabled: boolean; + isControlled: 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?: (isPM: number) => void; + timeFormat: "12" | "24"; + showSeconds?: boolean; + hourValue?: number; + minuteValue?: number; + secondValue?: number; + dayPeriod?: number; + id?: string; + tabIndex?: number; +}; + +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..1fcac0d63 --- /dev/null +++ b/packages/lib/src/time-input/utils.ts @@ -0,0 +1,122 @@ +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) { + 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>, + interactive: boolean, + rawInput: React.MutableRefObject<string>, + newDigit: React.MutableRefObject<string>, + spanRef: React.MutableRefObject<HTMLSpanElement | null>, + setInnerValue: React.Dispatch<React.SetStateAction<number | undefined>>, + innerValue: number | undefined, + maxValue: number, + minValue: number, + isDayPeriod?: boolean, + onChange?: (value: number | undefined) => void, + onComplete?: () => void, + onNext?: () => void, + onPrevious?: () => void +) => { + if (!interactive) return; + 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); + if (!spanRef.current) return; + if (rawInput.current === "") { + newValue = undefined; + } else { + newValue = parseInt(rawInput.current, 10); + } + } + + 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); + 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; + } else if (event.key === "ArrowUp") { + if (innerValue == null || innerValue >= maxValue) { + newValue = minValue; + } else { + newValue = resolveValue(innerValue + 1, maxValue, minValue); + } + } else if (event.key === "ArrowDown") { + if (innerValue == null || innerValue <= minValue) { + newValue = maxValue; + } else { + newValue = resolveValue(innerValue - 1, maxValue, minValue); + } + } else if (isDayPeriod && /^[apAP01]$/.test(event.key)) { + // AM/PM input + const isAM = /[aA0]/.test(event.key); + newValue = isAM ? 0 : 1; + } + setInnerValue((prevValue) => { + 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 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}` : "" + }`; +};