diff --git a/skills/carbon-react/components/numeral-date.md b/skills/carbon-react/components/numeral-date.md index 6937113b96..d959b5740b 100644 --- a/skills/carbon-react/components/numeral-date.md +++ b/skills/carbon-react/components/numeral-date.md @@ -84,6 +84,63 @@ ControlledNumeralDate ``` +### FlexibleRawInput + +**Render** + +```tsx +() => { + const [value, setValue] = useState({ + dd: "twenty-first", + mm: "März", + yyyy: "two thousand and twenty-six", + }); + + return ( + <> +

+ Carbon forwards raw values such as Jan, März, and août unchanged. The + consuming application owns parsing, sanitisation, and acceptance. +

+ setValue(event.target.value)} + /> + + ); + } +``` + + +### ConsumerValidation + +**Render** + +```tsx +() => { + const [value, setValue] = useState({ + dd: "21", + mm: "Not a month", + yyyy: "2026", + }); + const acceptedMonths = ["Jan", "März", "août"]; + const consumerError = acceptedMonths.includes(value.mm ?? "") + ? "" + : "The consuming application does not accept this month value."; + + return ( + setValue(event.target.value)} + /> + ); + } +``` + + ### WithLegendHint **Args** diff --git a/src/components/decimal/decimal.component.tsx b/src/components/decimal/decimal.component.tsx index df3482039b..677d3cadc6 100644 --- a/src/components/decimal/decimal.component.tsx +++ b/src/components/decimal/decimal.component.tsx @@ -83,6 +83,7 @@ export const Decimal = React.forwardRef( allowEmptyValue = false, fieldHelp, id, + inputMode = "decimal", inputWidth, labelHelp, locale, @@ -325,6 +326,7 @@ export const Decimal = React.forwardRef( value={stateValue} data-component="decimal" id={id} + inputMode={inputMode} ref={ref} prefix={prefix} fieldHelp={fieldHelp} diff --git a/src/components/decimal/decimal.test.tsx b/src/components/decimal/decimal.test.tsx index e0132297ff..4cba4e4b7d 100644 --- a/src/components/decimal/decimal.test.tsx +++ b/src/components/decimal/decimal.test.tsx @@ -48,6 +48,16 @@ it.each([ expect(screen.getByTestId("hidden-input")).toHaveValue(formattedValue); }); +it("uses a decimal input mode by default and allows an explicit override", () => { + const { rerender } = render(); + + expect(screen.getByRole("textbox")).toHaveAttribute("inputmode", "decimal"); + + rerender(); + + expect(screen.getByRole("textbox")).toHaveAttribute("inputmode", "numeric"); +}); + it("does not fire onChange when the input blurs with no change in value", async () => { const user = userEvent.setup(); const onChange = jest.fn(); diff --git a/src/components/numeral-date/numeral-date-interaction.stories.tsx b/src/components/numeral-date/numeral-date-interaction.stories.tsx new file mode 100644 index 0000000000..d377e13f48 --- /dev/null +++ b/src/components/numeral-date/numeral-date-interaction.stories.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; + +import NumeralDate, { NumeralDateProps } from "."; + +type Story = StoryObj; + +export default { + title: "Numeral Date/Interactions", + component: NumeralDate, + parameters: { chromatic: { disableSnapshot: true } }, +}; + +const ControlledNumeralDate = () => { + const [value, setValue] = useState({ + dd: "long-day-value", + mm: "Jan", + yyyy: "2026", + }); + + return ( + setValue(event.target.value)} + /> + ); +}; + +export const UnrestrictedEditing: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dayInput = canvas.getByRole("textbox", { + name: "Day", + }); + const monthInput = canvas.getByRole("textbox", { + name: "Month", + }); + + await userEvent.clear(monthInput); + await userEvent.type(monthInput, "Jan-long-raw-value"); + await expect(monthInput).toHaveValue("Jan-long-raw-value"); + + dayInput.focus(); + await expect(dayInput).toHaveFocus(); + dayInput.setSelectionRange(0, dayInput.value.length); + await expect(dayInput.selectionStart).toBe(0); + await expect(dayInput.selectionEnd).toBe(dayInput.value.length); + await userEvent.copy(); + + monthInput.focus(); + await expect(monthInput).toHaveFocus(); + monthInput.setSelectionRange(0, monthInput.value.length); + await expect(monthInput.selectionStart).toBe(0); + await expect(monthInput.selectionEnd).toBe(monthInput.value.length); + await userEvent.paste(); + await expect(monthInput).toHaveValue("long-day-value"); + + monthInput.setSelectionRange( + monthInput.value.length, + monthInput.value.length, + ); + await expect(monthInput.selectionStart).toBe(monthInput.value.length); + await expect(monthInput.selectionEnd).toBe(monthInput.value.length); + + await userEvent.keyboard("{ArrowLeft}"); + await expect(monthInput.selectionStart).toBe(monthInput.value.length - 1); + await expect(monthInput.selectionEnd).toBe(monthInput.value.length - 1); + + await userEvent.keyboard("{Home}"); + await expect(monthInput.selectionStart).toBe(0); + await expect(monthInput.selectionEnd).toBe(0); + + await userEvent.keyboard("{Shift>}{ArrowRight}{/Shift}"); + await expect(monthInput.selectionStart).toBe(0); + await expect(monthInput.selectionEnd).toBe(1); + + await userEvent.keyboard("{End}"); + await expect(monthInput.selectionStart).toBe(monthInput.value.length); + await expect(monthInput.selectionEnd).toBe(monthInput.value.length); + }, +}; diff --git a/src/components/numeral-date/numeral-date.component.tsx b/src/components/numeral-date/numeral-date.component.tsx index 2c0ec51b09..848c0a4eb6 100644 --- a/src/components/numeral-date/numeral-date.component.tsx +++ b/src/components/numeral-date/numeral-date.component.tsx @@ -11,7 +11,6 @@ import tagComponent, { TagProps } from "../../__internal__/utils/helpers/tags"; import { ValidationProps } from "../../__internal__/validations"; import { filterStyledSystemMarginProps } from "../../style/utils"; -import Events from "../../__internal__/utils/helpers/events"; import StyledNumeralDate from "./numeral-date.style"; import TextInput from "../textbox/__internal__/__next__"; import guid from "../../__internal__/utils/helpers/guid"; @@ -209,15 +208,42 @@ const validationMessages = ( yyyy: locale.numeralDate.validation.year(), }); -const getDaysInMonth = (month?: string, year?: string) => { - if (!month || +month > 12 || +month < 1) { - return 31; +const toNumericValue = (value?: string) => { + if (!value?.trim()) { + return undefined; + } + + const numericValue = Number(value); + + return Number.isNaN(numericValue) ? undefined : numericValue; +}; + +const getDayValidationContext = (month?: string, year?: string) => { + const numericMonth = toNumericValue(month); + if ( + numericMonth === undefined || + !Number.isInteger(numericMonth) || + numericMonth > 12 || + numericMonth < 1 + ) { + return { daysInMonth: 31 }; + } + + const numericYear = year ? toNumericValue(year) : new Date().getFullYear(); + if (numericYear === undefined || !Number.isInteger(numericYear)) { + return { daysInMonth: 31 }; } - const currentDate = new Date(); - const computedYear = +(year || currentDate.getFullYear()); // passing 0 as the third argument ensures we handle for months being 0 indexed - return new Date(computedYear, +month, 0).getDate(); + const daysInMonth = new Date(numericYear, numericMonth, 0).getDate(); + if (Number.isNaN(daysInMonth)) { + return { daysInMonth: 31 }; + } + + return { + daysInMonth, + monthForMessage: month, + }; }; const validate = (locale: Locale, { dd, mm, yyyy }: NumeralDateValue) => { @@ -226,17 +252,27 @@ const validate = (locale: Locale, { dd, mm, yyyy }: NumeralDateValue) => { mm: "", yyyy: "", }; - const daysInMonth = getDaysInMonth(mm, yyyy); - - if (dd && (+dd > daysInMonth || +dd < 1)) { - failed.dd = validationMessages(locale, mm, String(daysInMonth)).dd; + const { daysInMonth, monthForMessage } = getDayValidationContext(mm, yyyy); + const numericDay = toNumericValue(dd); + const numericMonth = toNumericValue(mm); + const numericYear = toNumericValue(yyyy); + + if ( + numericDay !== undefined && + (numericDay > daysInMonth || numericDay < 1) + ) { + failed.dd = validationMessages( + locale, + monthForMessage, + String(daysInMonth), + ).dd; } - if (mm && (+mm > 12 || +mm < 1)) { + if (numericMonth !== undefined && (numericMonth > 12 || numericMonth < 1)) { failed.mm = validationMessages(locale).mm; } - if (yyyy && (+yyyy < 1800 || +yyyy > 2200)) { + if (numericYear !== undefined && (numericYear < 1800 || numericYear > 2200)) { failed.yyyy = validationMessages(locale).yyyy; } @@ -359,33 +395,18 @@ export const NumeralDate = forwardRef( }, }); - const onKeyDown = (event: React.KeyboardEvent) => { - const isValidKey = - Events.isNumberKey(event) || - Events.isTabKey(event) || - Events.isEnterKey(event) || - event.key === "Delete" || - event.key === "Backspace"; - - if (!isValidKey) { - event.preventDefault(); - } - }; - const handleChange = ( event: React.ChangeEvent, datePart: keyof NumeralDateValue, ) => { const { value: newValue } = event.target; - if (newValue.length <= datePart.length) { - const newDateValue = { - ...value, - [datePart]: newValue, - }; + const newDateValue = { + ...value, + [datePart]: newValue, + }; - onChange(createCustomEventObject(newDateValue)); - } + onChange(createCustomEventObject(newDateValue)); }; const handleBlur = () => { @@ -442,7 +463,7 @@ export const NumeralDate = forwardRef( const renderInputs = () => { return ( - + {dateFormat.map((datePart, index) => { let inputRef: React.ForwardedRef | undefined; @@ -478,6 +499,7 @@ export const NumeralDate = forwardRef( warning={!!internalWarning} size={size} value={value[datePart] ?? ""} + inputMode="numeric" onChange={(e) => handleChange(e, datePart)} onBlur={handleBlur} ref={(element) => handleRef(element, index, inputRef)} diff --git a/src/components/numeral-date/numeral-date.mdx b/src/components/numeral-date/numeral-date.mdx index a5bf7db445..96a9c6d792 100644 --- a/src/components/numeral-date/numeral-date.mdx +++ b/src/components/numeral-date/numeral-date.mdx @@ -53,6 +53,19 @@ The available formats are: +### Flexible Raw Input + +Carbon forwards raw values such as `Jan`, `März`, and `août` unchanged — the consuming application owns parsing, +sanitisation, and acceptance. + + + +### Consumer Validation + +use the 'error' prop when the consuming application needs to reject a raw value. + + + ### Validating date values `NumeralDate` supports standard validation via the `error` prop, which can be used to display an error message when the date value is invalid. diff --git a/src/components/numeral-date/numeral-date.pw.tsx b/src/components/numeral-date/numeral-date.pw.tsx index 420bcac0d0..50eacbc98c 100644 --- a/src/components/numeral-date/numeral-date.pw.tsx +++ b/src/components/numeral-date/numeral-date.pw.tsx @@ -33,6 +33,46 @@ test.describe("NumeralDate component", () => { await expect(input).toHaveValue(inputValue); }); + test("supports unrestricted text, keyboard clipboard commands, and caret movement", async ({ + mount, + page, + }) => { + await mount( + , + ); + + const dayInput = numeralDateInput(page, 0); + const monthInput = numeralDateInput(page, 1); + const yearInput = numeralDateInput(page, 2); + + await monthInput.fill("März-long-raw-value"); + await yearInput.fill("year-without-a-length-limit"); + await expect(monthInput).toHaveValue("März-long-raw-value"); + await expect(yearInput).toHaveValue("year-without-a-length-limit"); + + await dayInput.selectText(); + await dayInput.press("ControlOrMeta+c"); + await monthInput.selectText(); + await monthInput.press("ControlOrMeta+v"); + await expect(monthInput).toHaveValue("long-day-value"); + + await monthInput.press("End"); + await monthInput.press("ArrowLeft"); + await expect(monthInput).toHaveJSProperty( + "selectionStart", + "long-day-value".length - 1, + ); + await monthInput.press("Home"); + await expect(monthInput).toHaveJSProperty("selectionStart", 0); + await monthInput.press("End"); + await expect(monthInput).toHaveJSProperty( + "selectionStart", + "long-day-value".length, + ); + }); + dynamicValidations.forEach(([month, day, year, validationString]) => { test(`should display dynamic internal error message when month is ${month}, day is ${day} and year is ${year}`, async ({ mount, diff --git a/src/components/numeral-date/numeral-date.stories.tsx b/src/components/numeral-date/numeral-date.stories.tsx index 02e483ed71..e79cba49b6 100644 --- a/src/components/numeral-date/numeral-date.stories.tsx +++ b/src/components/numeral-date/numeral-date.stories.tsx @@ -72,6 +72,53 @@ export const Default: Story = { legend: "Legend", }, }; + +export const FlexibleRawInput: Story = { + render: () => { + const [value, setValue] = useState({ + dd: "twenty-first", + mm: "März", + yyyy: "two thousand and twenty-six", + }); + + return ( + <> +

+ Carbon forwards raw values such as Jan, März, and août unchanged. The + consuming application owns parsing, sanitisation, and acceptance. +

+ setValue(event.target.value)} + /> + + ); + }, +}; + +export const ConsumerValidation: Story = { + render: () => { + const [value, setValue] = useState({ + dd: "21", + mm: "Not a month", + yyyy: "2026", + }); + const acceptedMonths = ["Jan", "März", "août"]; + const consumerError = acceptedMonths.includes(value.mm ?? "") + ? "" + : "The consuming application does not accept this month value."; + + return ( + setValue(event.target.value)} + /> + ); + }, +}; export const WithLegendHint: Story = { ...Default, args: { diff --git a/src/components/numeral-date/numeral-date.test.tsx b/src/components/numeral-date/numeral-date.test.tsx index 7e74785392..f39ed2d26c 100644 --- a/src/components/numeral-date/numeral-date.test.tsx +++ b/src/components/numeral-date/numeral-date.test.tsx @@ -1,5 +1,5 @@ import React, { useRef } from "react"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils"; @@ -1273,51 +1273,200 @@ describe("when `yearRef` prop is passed", () => { }); }); -test("should not call the onChange callback when the prop is set and the user types a value that exceeds the 'Day' input limit", async () => { - const onChange = jest.fn(); +test.each([ + ["Day", { dd: "12", mm: "", yyyy: "" }, "123"], + ["Month", { dd: "", mm: "12", yyyy: "" }, "123"], + ["Year", { dd: "", mm: "", yyyy: "2011" }, "20113"], +])( + "should forward unrestricted raw text from the '%s' input", + async (name, initialValue, expectedValue) => { + const onChange = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + const input = screen.getByRole("textbox", { name }); + await user.click(input); + await user.keyboard("{End}"); + await user.type(input, "3"); + + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: expect.objectContaining({ + [name === "Day" ? "dd" : name === "Month" ? "mm" : "yyyy"]: + expectedValue, + }), + }), + }), + ); + }, +); + +test("should expose numeric input mode hints and not cancel editing keydown events", () => { + render(); + + const dayInput = screen.getByRole("textbox", { name: "Day" }); + const monthInput = screen.getByRole("textbox", { name: "Month" }); + const yearInput = screen.getByRole("textbox", { name: "Year" }); + + expect(dayInput).toHaveAttribute("inputmode", "numeric"); + expect(monthInput).toHaveAttribute("inputmode", "numeric"); + expect(yearInput).toHaveAttribute("inputmode", "numeric"); + + ["ArrowLeft", "Home", "End", "a", "v"].forEach((key) => { + expect(fireEvent.keyDown(monthInput, { key, ctrlKey: key === "v" })).toBe( + true, + ); + }); +}); + +test.each(["Jan", "May", "Dec", "month-name-without-a-length-limit"])( + "should forward the raw month value '%s' without parsing it", + (rawMonth) => { + const onChange = jest.fn(); + render( + , + ); + + fireEvent.change(screen.getByRole("textbox", { name: "Month" }), { + target: { value: rawMonth }, + }); + + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: { dd: "", mm: rawMonth, yyyy: "" }, + }), + }), + ); + }, +); + +test("should not report numeric range failures for non-numeric raw values", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render( - , + , ); - const dayInput = screen.getByRole("textbox", { name: "Day" }); - await user.click(dayInput); - await user.keyboard("{End}"); - await user.type(dayInput, "3"); - expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("textbox", { name: "Month" })); + await user.tab(); + await user.tab(); + + expect(screen.queryByRole("validation-message")).not.toBeInTheDocument(); }); -test("should not call the onChange callback when the prop is set and the user types a value that exceeds the 'Month' input limit", async () => { - const onChange = jest.fn(); +test("should not report numeric range failures for whitespace raw values", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render( - , + , ); - const monthInput = screen.getByRole("textbox", { name: "Month" }); - await user.click(monthInput); - await user.keyboard("{End}"); - await user.type(monthInput, "3"); - expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("textbox", { name: "Day" })); + await user.tab(); + await user.tab(); + + expect(screen.queryByRole("validation-message")).not.toBeInTheDocument(); }); -test("should not call the onChange callback when the prop is set and the user types a value that exceeds the 'Year' input limit", async () => { - const onChange = jest.fn(); +test("should report numeric range failures for signed and fractional numeric raw values", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render( - , ); - const yearInput = screen.getByRole("textbox", { name: "Year" }); - await user.click(yearInput); - await user.keyboard("{End}"); - await user.type(yearInput, "3"); - expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("textbox", { name: "Day" })); + await user.tab(); + await user.tab(); + + expect(screen.getByText(/Day should be a number/)).toHaveTextContent( + "Day should be a number within a 1-31 range. " + + "Month should be a number within a 1-12 range. " + + "Year should be a number within a 1800-2200 range.", + ); }); +test.each([ + [ + { dd: "32", mm: "Jan", yyyy: "2026" }, + "Day should be a number within a 1-31 range.", + ], + [ + { dd: "32", mm: "02", yyyy: "year" }, + "Day should be a number within a 1-31 range.", + ], + [ + { dd: "32", mm: "02", yyyy: "1e100" }, + "Day should be a number within a 1-31 range. " + + "Year should be a number within a 1800-2200 range.", + ], +])( + "should retain the universal day range when contextual values cannot define a valid date", + async (initialValue, expectedMessage) => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + await user.click(screen.getByRole("textbox", { name: "Day" })); + await user.tab(); + await user.tab(); + + expect(screen.getByText(/Day should be a number/)).toHaveTextContent( + expectedMessage, + ); + }, +); + +test.each([ + ["scientific notation", { dd: "3.2e1", mm: "1.3e1", yyyy: "1e4" }], + ["Infinity", { dd: "Infinity", mm: "Infinity", yyyy: "Infinity" }], +])( + "should report numeric range failures for %s raw values", + async (_description, initialValue) => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + await user.click(screen.getByRole("textbox", { name: "Day" })); + await user.tab(); + await user.tab(); + + expect(screen.getByText(/Day should be a number/)).toHaveTextContent( + "Day should be a number within a 1-31 range. " + + "Month should be a number within a 1-12 range. " + + "Year should be a number within a 1800-2200 range.", + ); + }, +); + +test.each(["", "+", "-", ".", "+.", "-."])( + "should not report numeric range failures for incomplete numeric raw value '%s'", + async (rawValue) => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + await user.click(screen.getByRole("textbox", { name: "Day" })); + await user.tab(); + await user.tab(); + + expect(screen.queryByRole("validation-message")).not.toBeInTheDocument(); + }, +); + test("should set the passed `data-` props as attributes on the root element", () => { render( ( data-component="hours" ref={hoursRef} value={hourValue} + inputMode="numeric" onChange={(ev) => handleChange(ev, "hrs")} onBlur={handleBlur} id={internalHrsId.current} @@ -378,6 +379,7 @@ const Time = React.forwardRef( data-component="minutes" ref={minsRef} value={minuteValue} + inputMode="numeric" onChange={(ev) => handleChange(ev, "mins")} onBlur={handleBlur} id={internalMinsId.current} diff --git a/src/components/time/time.test.tsx b/src/components/time/time.test.tsx index eb8507cc86..66e78ad95d 100644 --- a/src/components/time/time.test.tsx +++ b/src/components/time/time.test.tsx @@ -319,6 +319,19 @@ test("should apply the custom id on the minutes input when `minutesInputProps` h expect(minutesInput).toHaveAttribute("id", "foo"); }); +test("should apply numeric input mode hints to the hours and minutes inputs", () => { + render(