Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions skills/carbon-react/components/numeral-date.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,63 @@ ControlledNumeralDate
```


### FlexibleRawInput

**Render**

```tsx
() => {
const [value, setValue] = useState<NumeralDateProps["value"]>({
dd: "twenty-first",
mm: "März",
yyyy: "two thousand and twenty-six",
});

return (
<>
<p>
Carbon forwards raw values such as Jan, März, and août unchanged. The
consuming application owns parsing, sanitisation, and acceptance.
</p>
<NumeralDate
legend="Flexible raw date"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</>
);
}
```


### ConsumerValidation

**Render**

```tsx
() => {
const [value, setValue] = useState<NumeralDateProps["value"]>({
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 (
<NumeralDate
legend="Consumer-validated raw date"
value={value}
error={consumerError}
onChange={(event) => setValue(event.target.value)}
/>
);
}
```


### WithLegendHint

**Args**
Expand Down
2 changes: 2 additions & 0 deletions src/components/decimal/decimal.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const Decimal = React.forwardRef(
allowEmptyValue = false,
fieldHelp,
id,
inputMode = "decimal",
inputWidth,
labelHelp,
locale,
Expand Down Expand Up @@ -325,6 +326,7 @@ export const Decimal = React.forwardRef(
value={stateValue}
data-component="decimal"
id={id}
inputMode={inputMode}
ref={ref}
prefix={prefix}
fieldHelp={fieldHelp}
Expand Down
10 changes: 10 additions & 0 deletions src/components/decimal/decimal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Decimal value="123" onChange={jest.fn} />);

expect(screen.getByRole("textbox")).toHaveAttribute("inputmode", "decimal");

rerender(<Decimal value="123" onChange={jest.fn} inputMode="numeric" />);

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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof NumeralDate>;

export default {
title: "Numeral Date/Interactions",
component: NumeralDate,
parameters: { chromatic: { disableSnapshot: true } },
};

const ControlledNumeralDate = () => {
const [value, setValue] = useState<NumeralDateProps["value"]>({
dd: "long-day-value",
mm: "Jan",
yyyy: "2026",
});

return (
<NumeralDate
legend="Flexible date input"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
);
};

export const UnrestrictedEditing: Story = {
render: () => <ControlledNumeralDate />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dayInput = canvas.getByRole<HTMLInputElement>("textbox", {
name: "Day",
});
const monthInput = canvas.getByRole<HTMLInputElement>("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);
},
};
90 changes: 56 additions & 34 deletions src/components/numeral-date/numeral-date.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
}

Expand Down Expand Up @@ -359,33 +395,18 @@ export const NumeralDate = forwardRef<NumeralDateHandle, NumeralDateProps>(
},
});

const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>,
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 = () => {
Expand Down Expand Up @@ -442,7 +463,7 @@ export const NumeralDate = forwardRef<NumeralDateHandle, NumeralDateProps>(

const renderInputs = () => {
return (
<StyledNumeralDate onKeyDown={onKeyDown} $size={size}>
<StyledNumeralDate $size={size}>
{dateFormat.map((datePart, index) => {
let inputRef: React.ForwardedRef<HTMLInputElement> | undefined;

Expand Down Expand Up @@ -478,6 +499,7 @@ export const NumeralDate = forwardRef<NumeralDateHandle, NumeralDateProps>(
warning={!!internalWarning}
size={size}
value={value[datePart] ?? ""}
inputMode="numeric"
onChange={(e) => handleChange(e, datePart)}
onBlur={handleBlur}
ref={(element) => handleRef(element, index, inputRef)}
Expand Down
13 changes: 13 additions & 0 deletions src/components/numeral-date/numeral-date.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ The available formats are:

<Canvas of={NumeralDateStories.DateFormats} />

### Flexible Raw Input

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

<Canvas of={NumeralDateStories.FlexibleRawInput} />

### Consumer Validation

use the 'error' prop when the consuming application needs to reject a raw value.

<Canvas of={NumeralDateStories.ConsumerValidation} />

### 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.
Expand Down
40 changes: 40 additions & 0 deletions src/components/numeral-date/numeral-date.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NumeralDateControlled
initialValue={{ dd: "long-day-value", mm: "Jan", yyyy: "2026" }}
/>,
);

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,
Expand Down
Loading
Loading