From bc47784de05a6d3dc5a1f92603639707ed439dca Mon Sep 17 00:00:00 2001 From: iryna Date: Tue, 21 Jul 2026 10:23:31 +0300 Subject: [PATCH 01/12] feat(date): align with fusion ds --- skills/carbon-react/components/date-input.md | 384 +++--- .../input/input-style-overrides.style.ts | 19 +- src/__internal__/label/label.style.ts | 10 +- src/__internal__/label/label.test.tsx | 18 + .../date-range-interaction.stories.tsx | 52 +- .../date-range/date-range.component.tsx | 2 +- src/components/date-range/date-range.mdx | 8 +- src/components/date-range/date-range.test.tsx | 57 +- .../calendar-close-button.component.tsx | 30 + .../calendar/calendar-close-button.style.ts | 14 + .../calendar-day-button.component.tsx | 42 + .../calendar-navigation.component.tsx | 83 ++ .../calendar/calendar-navigation.style.ts | 16 + .../calendar-select.component.tsx | 89 ++ .../calendar-select/calendar-select.config.ts | 38 + .../calendar-select/calendar-select.style.ts | 184 +++ .../calendar-select/calendar-select.test.tsx | 309 +++++ .../calendar/calendar-select/index.ts | 5 + ...useCustomizableSelectMarkup.server.test.ts | 5 + .../useCustomizableSelectMarkup.ts | 53 + .../calendar/calendar-weekday.component.tsx | 39 + .../calendar/calendar-weekday.style.ts | 31 + .../calendar/calendar.component.tsx | 130 ++ .../__internal__/calendar/calendar.test.tsx | 75 ++ .../date-picker-trigger.component.tsx | 84 ++ .../date-picker-trigger.style.ts | 157 +++ .../date-picker/date-picker-guard.test.tsx | 35 + .../date-picker/date-picker.component.tsx | 431 +++--- .../date-picker/date-picker.style.ts | 295 ++++ .../date-picker/date-picker.test.tsx | 513 ++----- .../date-picker/date-picker.types.ts | 42 + .../date-picker/date-picker.utils.test.ts | 156 +++ .../date-picker/date-picker.utils.ts | 154 +++ .../date-picker/day-picker.style.ts | 463 ------- .../date/__internal__/date-picker/index.ts | 8 +- .../hooks/useDateInputState.test.ts | 194 +++ .../__internal__/hooks/useDateInputState.ts | 95 ++ .../hooks/useDatePickerAccessibility.test.ts | 61 + .../hooks/useDatePickerAccessibility.ts | 26 + .../hooks/useDatePickerFocus.test.ts | 33 + .../__internal__/hooks/useDatePickerFocus.ts | 28 + .../hooks/useDatePickerInFlatTable.test.tsx | 65 + .../hooks/useDatePickerInFlatTable.ts | 21 + .../useDatePickerKeyboardNavigation.test.ts | 591 ++++++++ .../hooks/useDatePickerKeyboardNavigation.ts | 230 ++++ .../hooks/useDatePickerLocale.test.ts | 64 + .../__internal__/hooks/useDatePickerLocale.ts | 38 + .../hooks/useDatePickerMonthSync.test.ts | 161 +++ .../hooks/useDatePickerMonthSync.ts | 37 + .../useDatePickerMonthYearSelection.test.ts | 225 ++++ .../hooks/useDatePickerMonthYearSelection.ts | 114 ++ .../hooks/useDatePickerState.test.ts | 60 + .../__internal__/hooks/useDatePickerState.ts | 50 + .../date/__internal__/navbar/button.style.ts | 26 - .../date/__internal__/navbar/index.ts | 2 - .../__internal__/navbar/navbar.component.tsx | 58 - .../date/__internal__/navbar/navbar.style.ts | 13 - .../date/__internal__/navbar/navbar.test.tsx | 193 --- .../utils/date-input-value.invalid.test.ts | 11 + .../utils/date-input-value.utils.test.ts | 181 +++ .../utils/date-input-value.utils.ts | 178 +++ .../__internal__/{ => utils}/utils.test.ts | 8 +- .../date/__internal__/{ => utils}/utils.ts | 2 +- .../date/__internal__/weekday/abbr.style.ts | 7 - .../date/__internal__/weekday/index.ts | 2 - .../weekday/weekday.component.tsx | 22 - .../__internal__/weekday/weekday.style.ts | 22 - .../__internal__/weekday/weekday.test.tsx | 33 - src/components/date/components.test-pw.tsx | 46 +- .../date/date-interaction.stories.tsx | 242 +--- src/components/date/date-test.stories.tsx | 550 ++++++-- src/components/date/date-typical.test.tsx | 1192 +++++++++++++++++ src/components/date/date.component.tsx | 333 ++--- src/components/date/date.mdx | 72 +- src/components/date/date.pw.tsx | 482 +++---- src/components/date/date.stories.tsx | 431 +++--- src/components/date/date.style.ts | 18 +- src/components/date/date.test.tsx | 436 +++--- src/components/flat-table/flat-table.test.tsx | 35 +- .../i18n-provider/i18n-provider.test.tsx | 28 + src/locales/en-gb.ts | 4 + src/locales/locale.ts | 8 + 82 files changed, 7729 insertions(+), 3030 deletions(-) create mode 100644 src/components/date/__internal__/calendar/calendar-close-button.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-close-button.style.ts create mode 100644 src/components/date/__internal__/calendar/calendar-day-button.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-navigation.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-navigation.style.ts create mode 100644 src/components/date/__internal__/calendar/calendar-select/calendar-select.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-select/calendar-select.config.ts create mode 100644 src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts create mode 100644 src/components/date/__internal__/calendar/calendar-select/calendar-select.test.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-select/index.ts create mode 100644 src/components/date/__internal__/calendar/calendar-select/useCustomizableSelectMarkup.server.test.ts create mode 100644 src/components/date/__internal__/calendar/calendar-select/useCustomizableSelectMarkup.ts create mode 100644 src/components/date/__internal__/calendar/calendar-weekday.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar-weekday.style.ts create mode 100644 src/components/date/__internal__/calendar/calendar.component.tsx create mode 100644 src/components/date/__internal__/calendar/calendar.test.tsx create mode 100644 src/components/date/__internal__/date-picker-trigger/date-picker-trigger.component.tsx create mode 100644 src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts create mode 100644 src/components/date/__internal__/date-picker/date-picker-guard.test.tsx create mode 100644 src/components/date/__internal__/date-picker/date-picker.style.ts create mode 100644 src/components/date/__internal__/date-picker/date-picker.types.ts create mode 100644 src/components/date/__internal__/date-picker/date-picker.utils.test.ts create mode 100644 src/components/date/__internal__/date-picker/date-picker.utils.ts delete mode 100644 src/components/date/__internal__/date-picker/day-picker.style.ts create mode 100644 src/components/date/__internal__/hooks/useDateInputState.test.ts create mode 100644 src/components/date/__internal__/hooks/useDateInputState.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerAccessibility.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerAccessibility.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerFocus.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerFocus.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerInFlatTable.test.tsx create mode 100644 src/components/date/__internal__/hooks/useDatePickerInFlatTable.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerLocale.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerLocale.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerMonthSync.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerMonthSync.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerState.test.ts create mode 100644 src/components/date/__internal__/hooks/useDatePickerState.ts delete mode 100644 src/components/date/__internal__/navbar/button.style.ts delete mode 100644 src/components/date/__internal__/navbar/index.ts delete mode 100644 src/components/date/__internal__/navbar/navbar.component.tsx delete mode 100644 src/components/date/__internal__/navbar/navbar.style.ts delete mode 100644 src/components/date/__internal__/navbar/navbar.test.tsx create mode 100644 src/components/date/__internal__/utils/date-input-value.invalid.test.ts create mode 100644 src/components/date/__internal__/utils/date-input-value.utils.test.ts create mode 100644 src/components/date/__internal__/utils/date-input-value.utils.ts rename src/components/date/__internal__/{ => utils}/utils.test.ts (98%) rename src/components/date/__internal__/{ => utils}/utils.ts (99%) delete mode 100644 src/components/date/__internal__/weekday/abbr.style.ts delete mode 100644 src/components/date/__internal__/weekday/index.ts delete mode 100644 src/components/date/__internal__/weekday/weekday.component.tsx delete mode 100644 src/components/date/__internal__/weekday/weekday.style.ts delete mode 100644 src/components/date/__internal__/weekday/weekday.test.tsx create mode 100644 src/components/date/date-typical.test.tsx diff --git a/skills/carbon-react/components/date-input.md b/skills/carbon-react/components/date-input.md index 0e374d1488..6c1d5afee0 100644 --- a/skills/carbon-react/components/date-input.md +++ b/skills/carbon-react/components/date-input.md @@ -283,6 +283,7 @@ description: Carbon DateInput component props and usage examples. | unselectable | "off" \| "on" \| undefined | No | | | | | | | validationIconId | string \| undefined | No | | | | Id of the validation icon | | | validationMessagePositionTop | boolean \| undefined | No | | | | Render the ValidationMessage above the Textbox input when validationRedesignOptIn flag is set | | +| variant | "typical" \| "legacy" \| undefined | No | | | | Date input presentation. Typical is the default; legacy retains its icon trigger. | | | vocab | string \| undefined | No | | | | | | | warning | string \| boolean \| undefined | No | | | | Indicate that warning has occurred. | | | width | string \| number \| undefined | No | | | | | | @@ -363,17 +364,29 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("04/04/2019"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; + const [typicalValue, setTypicalValue] = useState(storyDateValue); + const [legacyValue, setLegacyValue] = useState(storyDateValue); + return ( - + + setTypicalValue(event.target.value.formattedValue)} + /> + setLegacyValue(event.target.value.formattedValue)} + /> + ); } ``` @@ -385,19 +398,7 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("04/04/2019"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ( - - ); + return ; } ``` @@ -408,23 +409,16 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; return ( - <> + {(["small", "medium", "large"] as const).map((size) => ( - + ))} - + ); } ``` @@ -436,11 +430,7 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ; + return ; } ``` @@ -451,11 +441,7 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ; + return ; } ``` @@ -466,177 +452,176 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState(""); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); + const [typicalValue, setTypicalValue] = useState(""); + const [legacyValue, setLegacyValue] = useState(""); + + const setValues = (value: string) => { + setTypicalValue(value); + setLegacyValue(value); }; + return ( <> - - + - + + + setTypicalValue(event.target.value.formattedValue) + } + allowEmptyValue + /> + + setLegacyValue(event.target.value.formattedValue) + } + allowEmptyValue + /> + ); } ``` -### Disabled Dates +### Disabled Dates in Calendar **Render** ```tsx -({ onChange, ...args }: DateInputProps) => { - const [state, setState] = useState("04/04/2019"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; +() => { + const isWeekend = (day: Date) => [0, 6].includes(day.getDay()); + return ( - { - setValue(ev); - onChange?.(ev); - }} - /> + + + Saturdays and Sundays are disabled in both calendars. + + + ); } ``` -### Disabled Dates using pickerProps +### With Label Inline **Render** ```tsx () => { - const [state, setState] = useState("04/04/2019"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - - const isWeekend = (day: Date) => [0, 6].includes(day.getDay()); - - return ( - - ); + return ; } ``` -### With Label Inline +### With Custom Width **Render** ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ( - - ); + return ; } ``` -### With Custom Width +### With Error **Render** ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; return ( - + + + + ); } ``` -### With Field Help +### With Caution **Render** ```tsx -() => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ( - ( + + - ); -} + + +) ``` -### With Disabled Portal +### Without Portal (Deprecated) **Render** ```tsx -() => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ( - - ); -} +() => ( + + + Deprecated compatibility behavior: disablePortal renders the calendar in + the Date Input's local DOM tree instead of through a portal. Existing + legacy and typical consumers remain supported, but new implementations + should not adopt this prop. + + + +) ``` @@ -646,11 +631,7 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("01/10/2016"); - const setValue = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - return ; + return ; } ``` @@ -661,16 +642,17 @@ description: Carbon DateInput component props and usage examples. ```tsx () => { - const [state, setState] = useState("2022-04-05"); - const handleChange = (ev: DateChangeEvent) => { - setState(ev.target.value.formattedValue); - }; - const [state2, setState2] = useState("2022-04-05"); - const handleChange2 = (ev: DateChangeEvent) => { - setState2(ev.target.value.formattedValue); - }; return ( - + + + Locale values are merged with en-GB. Applications should supply approved + translations for their users through I18nProvider; omitted accessibility + labels fall back to en-GB. + "de-DE", @@ -683,11 +665,7 @@ description: Carbon DateInput component props and usage examples. }, }} > - + - + + "pl-PL", + date: { + dateFnsLocale: () => pl, + ariaLabels: { + previousMonthButton: () => "Poprzedni miesiąc", + nextMonthButton: () => "Następny miesiąc", + }, + }, + }} + > + + + "en-GB", + date: { + dateFnsLocale: () => enGB, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, + }, + }} + > + @@ -733,7 +744,16 @@ description: Carbon DateInput component props and usage examples. }; return ( - + + + Both fields use the German locale. The first uses the locale translation + key, so it displays YYYY-MM-DD. The second passes dateFormatOverride + directly, so that prop takes precedence and displays DD/MM/YYYY. + "de-DE", @@ -749,7 +769,8 @@ description: Carbon DateInput component props and usage examples. > { handleChangeKey(ev); @@ -760,7 +781,8 @@ description: Carbon DateInput component props and usage examples. { handleChangeProp(ev); diff --git a/src/__internal__/input/input-style-overrides.style.ts b/src/__internal__/input/input-style-overrides.style.ts index 17b527db36..bc32bcef44 100644 --- a/src/__internal__/input/input-style-overrides.style.ts +++ b/src/__internal__/input/input-style-overrides.style.ts @@ -7,8 +7,21 @@ import addFocusStyling from "../../style/utils/add-focus-styling"; export const dateStyleOverrides = css` .date & { .input-text-container input { - padding: 0 0 0 12px; - margin-right: -12px; + padding-inline-end: 0; + } + } + + .date-typical [data-role="date-input-wrapper"] & { + &:focus-within { + box-shadow: none; + outline: none; + } + + .input-text-container input:focus { + border-radius: var(--global-radius-action-m) 0 0 + var(--global-radius-action-m); + ${addFocusStyling()} + z-index: 2; } } `; @@ -144,7 +157,6 @@ const searchNewBaseStyles = css` &:focus-within:has(:focus:not(button)) { box-shadow: none; - -webkit-box-shadow: none; } .input-text-container input[type="search"]:focus { @@ -331,7 +343,6 @@ export const passwordStyleOverrides = css` [data-component="password"] & { &:focus-within { box-shadow: none !important; - -webkit-box-shadow: none !important; outline: none !important; } diff --git a/src/__internal__/label/label.style.ts b/src/__internal__/label/label.style.ts index edbb074a05..8f5056e02d 100644 --- a/src/__internal__/label/label.style.ts +++ b/src/__internal__/label/label.style.ts @@ -44,16 +44,14 @@ const StyledLabel = styled.label` ${({ $isRequired, $size, $disabled }) => $isRequired && css` - display: inline-flex; - align-items: center; - ::after { - content: "*"; color: ${$disabled ? "var(--input-labelset-label-disabled)" : "var(--input-labelset-label-required)"}; + content: "*"; + display: inline-block; font: ${getFontToken($size)}; - margin-left: 4px; + margin-inline-start: 4px; } `} @@ -65,7 +63,7 @@ const StyledLabel = styled.label` .fieldset-required-input & { ::after { content: ""; - margin-left: 0; + margin-inline-start: 0; } } `; diff --git a/src/__internal__/label/label.test.tsx b/src/__internal__/label/label.test.tsx index 3bde6a4f6b..a896ea9cf6 100644 --- a/src/__internal__/label/label.test.tsx +++ b/src/__internal__/label/label.test.tsx @@ -120,6 +120,24 @@ test("should apply medium font to required indicator by default", () => { ); }); +test("should apply small font to label and required indicator", () => { + render( + , + ); + + expect(screen.getByText("Small Required")).toHaveStyleRule( + "font", + "var(--global-font-static-comp-medium-s)", + ); + expect(screen.getByText("Small Required")).toHaveStyleRule( + "font", + "var(--global-font-static-comp-medium-s)", + { modifier: "::after" }, + ); +}); + test("should apply disabled colour and set aria-disabled when `disabled` prop is true", () => { render( , ); @@ -793,7 +870,7 @@ describe("when the `locale` is 'de-DE'", () => { date: { ariaLabels, dateFnsLocale: () => deLocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -854,7 +931,7 @@ describe("when the `locale` is 'de-DE'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -902,7 +979,7 @@ describe("when the `locale` is 'es'", () => { date: { ariaLabels, dateFnsLocale: () => esLocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -963,7 +1040,7 @@ describe("when the `locale` is 'es'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1011,7 +1088,7 @@ describe("when the `locale` is 'en-ZA'", () => { date: { ariaLabels, dateFnsLocale: () => enZALocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1072,7 +1149,7 @@ describe("when the `locale` is 'en-ZA'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1120,7 +1197,7 @@ describe("when the `locale` is 'fr-FR'", () => { date: { ariaLabels, dateFnsLocale: () => frLocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1181,7 +1258,7 @@ describe("when the `locale` is 'fr-FR'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1229,7 +1306,7 @@ describe("when the `locale` is 'fr-CA'", () => { date: { ariaLabels, dateFnsLocale: () => frCALocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1290,7 +1367,7 @@ describe("when the `locale` is 'fr-CA'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1338,7 +1415,7 @@ describe("when the `locale` is 'en-CA'", () => { date: { ariaLabels, dateFnsLocale: () => enCALocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1399,7 +1476,7 @@ describe("when the `locale` is 'en-CA'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1447,7 +1524,7 @@ describe("when the `locale` is 'en-US'", () => { date: { ariaLabels, dateFnsLocale: () => enUSLocale }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1508,7 +1585,7 @@ describe("when the `locale` is 'en-US'", () => { }, }} > - {}} value="2019-04-05" /> + {}} value="2019-04-05" /> , ); @@ -1597,7 +1674,7 @@ test("should call `onChange` with expected parameters but not update the input v test("should render the validation message when an `error` is passed a string value and the user hovers the mouse over the input", () => { render( - {}} value="" @@ -1611,7 +1688,7 @@ test("should render the validation message when an `error` is passed a string va }); test("should not render the validation message when `error` is passed a boolean value", () => { - render( {}} value="" error />); + render( {}} value="" error />); const input = screen.getByRole("textbox"); expect(input).toHaveAttribute("aria-invalid", "true"); @@ -1620,7 +1697,7 @@ test("should not render the validation message when `error` is passed a boolean test("should render the validation message when an `warning` is passed a string value and the user hovers the mouse over the input", () => { render( - {}} value="" @@ -1634,7 +1711,9 @@ test("should render the validation message when an `warning` is passed a string }); test("should not render the validation message when `warning` is passed a boolean value", () => { - render( {}} value="" warning />); + render( + {}} value="" warning />, + ); const input = screen.getByRole("textbox"); expect(input).toHaveAttribute("aria-invalid", "false"); @@ -1646,7 +1725,7 @@ test("should call `onPickerOpen` callback when the user opens the DatePicker and const onPickerOpen = jest.fn(); const onPickerClose = jest.fn(); render( - {}} onPickerOpen={onPickerOpen} @@ -1663,6 +1742,29 @@ test("should call `onPickerOpen` callback when the user opens the DatePicker and expect(onPickerClose).toHaveBeenCalled(); }); +test("should close the legacy DatePicker with its close button and return focus to the input", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onPickerClose = jest.fn(); + render( + {}} + onPickerClose={onPickerClose} + value="010122" + />, + ); + + const input = screen.getByRole("textbox", { name: "label" }); + await user.click(screen.getByTestId("input-icon-toggle")); + await user.click(screen.getByRole("button", { name: "Close" })); + + expect( + screen.queryByRole("button", { name: "Close" }), + ).not.toBeInTheDocument(); + expect(input).toHaveFocus(); + expect(onPickerClose).toHaveBeenCalledTimes(1); +}); + test("should select the correct date when the locale is overridden and a date is typed into the input", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const onChange = jest.fn(); @@ -1678,7 +1780,7 @@ test("should select the correct date when the locale is overridden and a date is }, }} > - + , ); const input = screen.getByRole("textbox"); @@ -1687,8 +1789,12 @@ test("should select the correct date when the locale is overridden and a date is const calendarIcon = screen.getByTestId("input-icon-toggle"); await user.click(calendarIcon); - const caption = screen.getByRole("status"); - expect(caption).toHaveTextContent("April 2019"); + expect( + screen.getByRole("combobox", { name: "Choose the month" }), + ).toHaveValue("3"); + expect(screen.getByRole("combobox", { name: "Choose the year" })).toHaveValue( + "2019", + ); }); // coverage @@ -1697,7 +1803,7 @@ test("should correctly focus when disablePortal is false and Tab is used to navi const onFocus = jest.fn(); const onClick = jest.fn(); render( - {}} value="2019-04-04" @@ -1713,9 +1819,12 @@ test("should correctly focus when disablePortal is false and Tab is used to navi await user.tab(); await user.tab(); await user.tab(); + await user.tab(); expect( - screen.getByLabelText("Thursday, April 4th, 2019", { exact: false }), + screen.getByRole("button", { + name: "Thursday, April 4th, 2019, selected", + }), ).toHaveFocus(); }); @@ -1726,7 +1835,7 @@ test("should fire the onPickerClose callback when the input has focus and shift- const onPickerClose = jest.fn(); render( - { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - const onChange = jest.fn(); - - render( - , - ); - const calendarIcon = screen.getByTestId("input-icon-toggle"); - await user.click(calendarIcon); - await user.tab(); - const calendar = await screen.findByRole("grid"); - - expect(calendar).toBeVisible(); - expect( - screen.getByRole("button", { name: "Previous month" }), - ).toHaveFocus(); - }, -); diff --git a/src/components/flat-table/flat-table.test.tsx b/src/components/flat-table/flat-table.test.tsx index 9b474a5420..ba7e1ccc50 100644 --- a/src/components/flat-table/flat-table.test.tsx +++ b/src/components/flat-table/flat-table.test.tsx @@ -1,6 +1,12 @@ import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FlatTable from "./flat-table.component"; @@ -1378,20 +1384,14 @@ test("when a DateInput is opened in an expandable row and FlatTable has sticky f , ); - const preventDefaultSpy = jest.spyOn( - KeyboardEvent.prototype, - "preventDefault", - ); - const flatTableRow = screen.getByTestId("flat-table-row-0"); + const table = screen.getByRole("table"); await user.click(flatTableRow); - await user.keyboard("{PageDown}"); - await user.keyboard("{PageUp}"); - await user.keyboard("{Home}"); - await user.keyboard("{End}"); - - expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(fireEvent.keyDown(table, { key: "PageDown" })).toBe(true); + expect(fireEvent.keyDown(table, { key: "PageUp" })).toBe(true); + expect(fireEvent.keyDown(table, { key: "Home" })).toBe(true); + expect(fireEvent.keyDown(table, { key: "End" })).toBe(true); const calendarIcon = screen.getByTestId("input-icon-toggle"); await user.click(calendarIcon); @@ -1400,11 +1400,8 @@ test("when a DateInput is opened in an expandable row and FlatTable has sticky f expect(screen.getByRole("grid")).toBeVisible(); }); - await user.keyboard("{PageDown}"); - await user.keyboard("{PageUp}"); - await user.keyboard("{Home}"); - await user.keyboard("{End}"); - - expect(preventDefaultSpy).toHaveBeenCalled(); - preventDefaultSpy.mockRestore(); + expect(fireEvent.keyDown(table, { key: "PageDown" })).toBe(false); + expect(fireEvent.keyDown(table, { key: "PageUp" })).toBe(false); + expect(fireEvent.keyDown(table, { key: "Home" })).toBe(false); + expect(fireEvent.keyDown(table, { key: "End" })).toBe(false); }); diff --git a/src/components/i18n-provider/i18n-provider.test.tsx b/src/components/i18n-provider/i18n-provider.test.tsx index 912ec02dd7..5045f8ce3c 100644 --- a/src/components/i18n-provider/i18n-provider.test.tsx +++ b/src/components/i18n-provider/i18n-provider.test.tsx @@ -17,6 +17,20 @@ const TestComponent = () => { ); }; +const DateAriaLabels = () => { + const { ariaLabels } = useLocale().date; + + return ( + <> + {ariaLabels.previousMonthButton()} + {ariaLabels.openCalendarButton?.()} + {ariaLabels.chooseMonth?.()} + {ariaLabels.chooseYear?.()} + {ariaLabels.closeButton?.()} + + ); +}; + it("defaults to the 'en-gb' locale when no other locale is specified", () => { render( @@ -39,6 +53,20 @@ it("should support overriding the default locale with 'de-DE'", () => { expect(localeText).not.toHaveTextContent("en-GB"); }); +it("falls back to en-GB for date aria labels omitted by another locale", () => { + render( + + + , + ); + + expect(screen.getByText("Vorheriger Monat")).toBeVisible(); + expect(screen.getByText("Open calendar")).toBeVisible(); + expect(screen.getByText("Choose the month")).toBeVisible(); + expect(screen.getByText("Choose the year")).toBeVisible(); + expect(screen.getByText("Close")).toBeVisible(); +}); + it("should support overriding the default locale with 'en-CA'", () => { render( diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts index b4010c86b0..e2040c66d5 100644 --- a/src/locales/en-gb.ts +++ b/src/locales/en-gb.ts @@ -46,6 +46,10 @@ const enGB: Locale = { ariaLabels: { previousMonthButton: () => "Previous month", nextMonthButton: () => "Next month", + openCalendarButton: () => "Open calendar", + chooseMonth: () => "Choose the month", + chooseYear: () => "Choose the year", + closeButton: () => "Close", }, dateFormatOverride: undefined, }, diff --git a/src/locales/locale.ts b/src/locales/locale.ts index 344aef86b8..a41227b487 100644 --- a/src/locales/locale.ts +++ b/src/locales/locale.ts @@ -34,6 +34,14 @@ interface Locale { ariaLabels: { previousMonthButton: () => string; nextMonthButton: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + openCalendarButton?: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + chooseMonth?: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + chooseYear?: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + closeButton?: () => string; }; dateFormatOverride?: string; }; From 382d6f1b346674d2e81b89791fb550c40fd36f2e Mon Sep 17 00:00:00 2001 From: iryna Date: Mon, 10 Aug 2026 18:29:51 +0300 Subject: [PATCH 02/12] feat(date): fix click away behaviour and some tests --- .../z-index-interaction.stories.tsx | 15 ++--- .../date-range-interaction.stories.tsx | 63 ++++++++++++------- src/components/date/date.component.tsx | 6 +- src/components/date/date.pw.tsx | 53 ++++++++++++++++ .../form/form-interaction.stories.tsx | 6 ++ 5 files changed, 112 insertions(+), 31 deletions(-) diff --git a/src/__internal__/__stories__/z-index-interaction.stories.tsx b/src/__internal__/__stories__/z-index-interaction.stories.tsx index ce4b2f6fb9..81d56576a0 100644 --- a/src/__internal__/__stories__/z-index-interaction.stories.tsx +++ b/src/__internal__/__stories__/z-index-interaction.stories.tsx @@ -74,8 +74,10 @@ export const DateInputInDialog: StoryObj = { await expect(modal).toHaveAttribute("data-state", "open"); }); - const icon = await screen.findByTestId("icon"); - await userEvent.click(icon); + const calendarButton = await screen.findByRole("button", { + name: "Open calendar", + }); + await userEvent.click(calendarButton); const datePicker = await screen.findByTestId("date-picker"); expect(datePicker).toBeVisible(); @@ -458,11 +460,10 @@ export const DateInPopoverContainer: StoryObj = { play: async () => { if (!allowInteractions()) return; - const icon = document.querySelector( - '[data-component="icon"][data-element="calendar"]', - ); - - if (icon) await userEvent.click(icon); + const calendarButton = await screen.findByRole("button", { + name: "Open calendar", + }); + await userEvent.click(calendarButton); const datePicker = await screen.findByTestId("date-picker"); expect(datePicker).toBeVisible(); diff --git a/src/components/date-range/date-range-interaction.stories.tsx b/src/components/date-range/date-range-interaction.stories.tsx index 7102e957be..6177f861c8 100644 --- a/src/components/date-range/date-range-interaction.stories.tsx +++ b/src/components/date-range/date-range-interaction.stories.tsx @@ -128,16 +128,21 @@ export const MinMaxDateRange: Story = { const startInput = canvas.getByRole("textbox", { name: /start date/i }); const endInput = canvas.getByRole("textbox", { name: /end date/i }); - // Navigate to previous month — all days there are before minDate ("2025-07-14"), - // so pressing Enter on a disabled day should not commit a new value. await userEvent.click(startCalendarButton); - await userEvent.keyboard("{PageUp}{End}{Enter}"); + const dateBeforeMinimum = canvas.getByRole("button", { + name: /Sunday, July 13th, 2025/i, + }); + await expect(dateBeforeMinimum).toBeDisabled(); + await userEvent.click(dateBeforeMinimum); await expect(startInput).toHaveValue("17/07/2025"); + await userEvent.click(startCalendarButton); - // Navigate to next month — all days there are after maxDate ("2025-07-30"), - // so pressing Enter on a disabled day should not commit a new value. await userEvent.click(endCalendarButton); - await userEvent.keyboard("{PageDown}{Home}{Enter}"); + const dateAfterMaximum = canvas.getByRole("button", { + name: /Thursday, July 31st, 2025/i, + }); + await expect(dateAfterMaximum).toBeDisabled(); + await userEvent.click(dateAfterMaximum); await expect(endInput).toHaveValue("20/07/2025"); }, }; @@ -169,13 +174,24 @@ export const DisabledDaysDateRange: Story = { "button", { name: "Open calendar" }, ); + const startInput = canvas.getByRole("textbox", { name: /start date/i }); const endInput = canvas.getByRole("textbox", { name: /end date/i }); await userEvent.click(startCalendarButton); - await userEvent.keyboard("{ArrowLeft}{Enter}"); + const disabledRangeDate = canvas.getByRole("button", { + name: /Wednesday, July 16th, 2025/i, + }); + await expect(disabledRangeDate).toBeDisabled(); + await userEvent.click(disabledRangeDate); + await expect(startInput).toHaveValue("10/07/2025"); + await userEvent.click(startCalendarButton); await userEvent.click(endCalendarButton); - await userEvent.keyboard("{ArrowRight}{ArrowDown}"); + const disabledWeekendDate = canvas.getByRole("button", { + name: /Saturday, July 26th, 2025/i, + }); + await expect(disabledWeekendDate).toBeDisabled(); + await userEvent.click(disabledWeekendDate); await expect(endInput).toHaveValue("25/07/2025"); }, }; @@ -200,13 +216,6 @@ export const FocusStateDateRange: Story = { const selectedDayBtn = canvas.getByRole("button", { name: /selected$/i }); - // The picker's internal tab order depth is variable, so we tab up to 6 times - // until focus lands on the selected day button. - const doc = canvasElement.ownerDocument as Document; - for (let i = 0; i < 6 && doc.activeElement !== selectedDayBtn; i += 1) { - await userEvent.tab(); - } - await expect(selectedDayBtn).toHaveFocus(); }, }; @@ -269,6 +278,7 @@ export const LocaleDeDateRange: Story = { ariaLabels: { previousMonthButton: () => "de-DE-previous", nextMonthButton: () => "de-DE-next", + chooseMonth: () => "de-DE-month", }, }, }} @@ -290,9 +300,13 @@ export const LocaleDeDateRange: Story = { }); await userEvent.click(startCalendarButton); - const prevMonth = canvas.getByRole("button", { name: /de-DE-previous/i }); - await userEvent.click(prevMonth); - await expect(prevMonth).toHaveFocus(); + const monthSelect = canvas.getByRole("combobox", { + name: /de-DE-month/i, + }); + await userEvent.selectOptions(monthSelect, "5"); + await expect(monthSelect).toHaveValue("5"); + await expect(monthSelect).toHaveDisplayValue("Juni"); + await expect(monthSelect).toHaveFocus(); }, }; @@ -337,14 +351,17 @@ export const MonthYearNavigationDateRange: Story = { ), play: async ({ canvasElement }) => { if (!allowInteractions()) return; - const c = within(canvasElement); - const [startCalendarButton] = c.getAllByRole("button", { + const canvas = within(canvasElement); + const [startCalendarButton] = canvas.getAllByRole("button", { name: "Open calendar", }); await userEvent.click(startCalendarButton); - const next = c.getByRole("button", { name: /next month/i }); - await userEvent.click(next); - await expect(next).toHaveFocus(); + const monthSelect = canvas.getByRole("combobox", { + name: "Choose the month", + }); + await userEvent.selectOptions(monthSelect, "7"); + await expect(monthSelect).toHaveValue("7"); + await expect(monthSelect).toHaveFocus(); }, }; MonthYearNavigationDateRange.parameters = { diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index 9709f6cbd7..c0fcebf38c 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -313,6 +313,8 @@ export const DateInput = React.forwardRef( }; const handleMouseDown = () => { + handleClickInside(); + if (setInputRefMap) { isBlurBlocked.current = true; } @@ -335,6 +337,8 @@ export const DateInput = React.forwardRef( }; const handleTriggerMouseDown = () => { + handleClickInside(); + if (disabled || readOnly) { return; } @@ -344,6 +348,7 @@ export const DateInput = React.forwardRef( }; const handlePickerMouseDown = () => { + handleClickInside(); isBlurBlocked.current = true; }; @@ -389,7 +394,6 @@ export const DateInput = React.forwardRef( "data-element": dataElement, "data-role": dataRole, })} - onMouseDown={handleClickInside} className={`date date-${variant}`} > { await expect(page.getByRole("dialog")).toBeHidden(); }); + [true, false].forEach((disablePortal) => { + test(`closes the typical ${ + disablePortal ? "non-portalled" : "portalled" + } date picker when clicking outside the input and picker after keyboard navigation`, async ({ + mount, + page, + }) => { + await mount( + , + ); + + await page.getByRole("button", { name: "Open calendar" }).click(); + + const datePicker = page.getByTestId("date-picker"); + await expect(datePicker).toBeVisible(); + + const selectedDay = page.getByRole("button", { + name: "Sunday, May 1st, 2022, selected", + }); + await expect(selectedDay).toBeFocused(); + for (const key of [ + "ArrowDown", + "ArrowDown", + "ArrowDown", + "ArrowDown", + "ArrowUp", + "ArrowLeft", + ]) { + await page.keyboard.press(key); + } + + const dateWrapper = page.locator('[data-component="date"]'); + const wrapperBox = (await dateWrapper.boundingBox()) as { + x: number; + y: number; + width: number; + height: number; + }; + // Click near the bottom-right edge of the wrapper — inside the wrapper + // boundary but outside the text input and picker, to verify the + // click-away handler closes the picker in this scenario. + await page.mouse.click( + wrapperBox.x + wrapperBox.width - 8, + wrapperBox.y + wrapperBox.height - 8, + ); + + await expect(datePicker).toBeHidden(); + }); + }); + [ ["bottom", 0], ["top", 400], diff --git a/src/components/form/form-interaction.stories.tsx b/src/components/form/form-interaction.stories.tsx index 643544fb1f..bd1d75dc30 100644 --- a/src/components/form/form-interaction.stories.tsx +++ b/src/components/form/form-interaction.stories.tsx @@ -123,6 +123,9 @@ export const FocusManagement: Story = { const datepickerInput = canvas.getByRole("textbox", { name: /date picker/i, }); + const datepickerButton = canvas.getByRole("button", { + name: /open calendar/i, + }); const textarea = canvas.getByRole("textbox", { name: /textbox 3/i }); const checkboxes = canvas.getAllByRole("checkbox", { name: /checkbox/i }); const tertiaryButton = canvas.getByRole("button", { name: /tertiary/i }); @@ -144,6 +147,9 @@ export const FocusManagement: Story = { await userEvent.tab(); await expect(datepickerInput).toHaveFocus(); + await userEvent.tab(); + await expect(datepickerButton).toHaveFocus(); + await userEvent.tab(); await expect(textarea).toHaveFocus(); From 5eed28aca0583418bab2d92cda36a16a6c5b79af Mon Sep 17 00:00:00 2001 From: iryna Date: Thu, 13 Aug 2026 18:36:28 +0300 Subject: [PATCH 03/12] feat(date): fix select styles, fix close button styles --- .../calendar/calendar-close-button.style.ts | 6 +-- .../calendar-navigation.component.tsx | 5 --- .../calendar/calendar-navigation.style.ts | 7 +-- .../calendar-select.component.tsx | 6 +-- .../calendar-select/calendar-select.config.ts | 38 ---------------- .../calendar-select/calendar-select.style.ts | 43 +++++++------------ .../calendar/calendar-weekday.component.tsx | 8 +--- .../calendar/calendar-weekday.style.ts | 1 + .../__internal__/calendar/calendar.test.tsx | 1 - .../date-picker/date-picker.component.tsx | 2 - .../date-picker/date-picker.style.ts | 4 +- 11 files changed, 23 insertions(+), 98 deletions(-) delete mode 100644 src/components/date/__internal__/calendar/calendar-select/calendar-select.config.ts diff --git a/src/components/date/__internal__/calendar/calendar-close-button.style.ts b/src/components/date/__internal__/calendar/calendar-close-button.style.ts index f48caf956f..56a3c8c5be 100644 --- a/src/components/date/__internal__/calendar/calendar-close-button.style.ts +++ b/src/components/date/__internal__/calendar/calendar-close-button.style.ts @@ -4,11 +4,7 @@ import Button from "../../../button/__next__"; const StyledCalendarCloseButton = styled(Button)` align-self: flex-end; - - @media (max-width: 480px) { - align-self: stretch; - width: 100%; - } + padding-block: var(--global-space-comp-xs); `; export default StyledCalendarCloseButton; diff --git a/src/components/date/__internal__/calendar/calendar-navigation.component.tsx b/src/components/date/__internal__/calendar/calendar-navigation.component.tsx index 0a3fb3278b..c0efc469ad 100644 --- a/src/components/date/__internal__/calendar/calendar-navigation.component.tsx +++ b/src/components/date/__internal__/calendar/calendar-navigation.component.tsx @@ -12,7 +12,6 @@ interface CalendarNavigationProps { monthSelectId: string; yearSelectId: string; years: number[]; - size: "small" | "medium" | "large"; localize?: { month: (month: DateFnsMonth, options?: { width?: "wide" }) => string; }; @@ -27,7 +26,6 @@ const CalendarNavigation = ({ monthSelectId, yearSelectId, years, - size, localize, onMonthChange, onYearChange, @@ -49,7 +47,6 @@ const CalendarNavigation = ({ return ( ({ value: year, label: String(year) }))} value={displayedYear} onChange={onYearChange} diff --git a/src/components/date/__internal__/calendar/calendar-navigation.style.ts b/src/components/date/__internal__/calendar/calendar-navigation.style.ts index 135e4d2c53..6a23aa7e3c 100644 --- a/src/components/date/__internal__/calendar/calendar-navigation.style.ts +++ b/src/components/date/__internal__/calendar/calendar-navigation.style.ts @@ -1,15 +1,12 @@ import styled from "styled-components"; -import { propsForSize as buttonSizeConfig } from "../../../button/__next__/button.config"; -import type { DatePickerSize } from "../date-picker"; - -const CalendarNavigation = styled.div<{ $size: DatePickerSize }>` +const CalendarNavigation = styled.div` display: flex; justify-content: space-between; align-items: center; width: var(--date-picker-grid-width); max-width: 100%; - min-height: ${({ $size }) => buttonSizeConfig[$size].height}; + min-height: var(--global-size-s); margin: 0 auto; `; diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.component.tsx b/src/components/date/__internal__/calendar/calendar-select/calendar-select.component.tsx index d66fedc16c..50522c011f 100644 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.component.tsx +++ b/src/components/date/__internal__/calendar/calendar-select/calendar-select.component.tsx @@ -10,7 +10,6 @@ import { StyledSelect, StyledWrapper, } from "./calendar-select.style"; -import { CalendarSelectSize } from "./calendar-select.config"; import useCustomizableSelectMarkup from "./useCustomizableSelectMarkup"; export interface CalendarSelectOption { @@ -26,7 +25,6 @@ export interface CalendarSelectProps >, TagProps { options?: CalendarSelectOption[]; - size?: CalendarSelectSize; } export const CalendarSelect = forwardRef< @@ -38,7 +36,6 @@ export const CalendarSelect = forwardRef< className, disabled, options, - size = "medium", value, "data-element": dataElement, "data-role": dataRole, @@ -61,7 +58,6 @@ export const CalendarSelect = forwardRef< > ))} - + diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.config.ts b/src/components/date/__internal__/calendar/calendar-select/calendar-select.config.ts deleted file mode 100644 index 0813a9c09c..0000000000 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { DatePickerSize } from "../../date-picker"; - -export type CalendarSelectSize = DatePickerSize; - -interface CalendarSelectSizeConfig { - checkmarkWidth: string; - controlFont: string; - optionFont: string; - inlinePadding: string; - blockPadding: string; -} - -export const calendarSelectSizeConfig: Record< - CalendarSelectSize, - CalendarSelectSizeConfig -> = { - small: { - checkmarkWidth: "var(--global-size-2-xs)", - controlFont: "var(--global-font-static-comp-medium-s)", - optionFont: "var(--global-font-static-comp-regular-s)", - inlinePadding: "var(--global-space-comp-s)", - blockPadding: "var(--global-space-comp-xs)", - }, - medium: { - checkmarkWidth: "var(--global-size-xs)", - controlFont: "var(--global-font-static-comp-medium-m)", - optionFont: "var(--global-font-static-comp-regular-m)", - inlinePadding: "var(--global-space-comp-m)", - blockPadding: "var(--global-space-comp-s)", - }, - large: { - checkmarkWidth: "var(--global-size-xs)", - controlFont: "var(--global-font-static-comp-medium-l)", - optionFont: "var(--global-font-static-comp-regular-l)", - inlinePadding: "var(--global-space-comp-l)", - blockPadding: "var(--global-space-comp-m)", - }, -}; diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts index 5426f12b5b..b2731d075c 100644 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts +++ b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts @@ -1,12 +1,6 @@ import styled, { css } from "styled-components"; -import { propsForSize as buttonSizeConfig } from "../../../../button/__next__/button.config"; import addFocusStyling from "../../../../../style/utils/add-focus-styling"; -import { - calendarSelectSizeConfig, - CalendarSelectSize, -} from "./calendar-select.config"; - export const StyledWrapper = styled.div<{ $disabled?: boolean }>` position: relative; display: inline-flex; @@ -22,9 +16,7 @@ export const StyledWrapper = styled.div<{ $disabled?: boolean }>` `} `; -export const StyledSelect = styled.select<{ - $size: CalendarSelectSize; -}>` +export const StyledSelect = styled.select` appearance: none; -webkit-appearance: none; display: inline-flex; @@ -35,17 +27,16 @@ export const StyledSelect = styled.select<{ border: none; outline: none; border-radius: var(--global-radius-container-m); - padding: ${({ $size }) => calendarSelectSizeConfig[$size].blockPadding} + padding: var(--global-space-comp-xs) calc( - ${({ $size }) => calendarSelectSizeConfig[$size].inlinePadding} + - var(--global-size-2-xs) + var(--global-space-comp-xs) + var(--global-space-comp-m) + var(--global-size-2-xs) + + var(--global-space-comp-xs) ) - ${({ $size }) => calendarSelectSizeConfig[$size].blockPadding} - ${({ $size }) => calendarSelectSizeConfig[$size].inlinePadding}; - min-height: ${({ $size }) => buttonSizeConfig[$size].height}; + var(--global-space-comp-xs) var(--global-space-comp-m); + min-height: var(--global-size-s); min-width: var(--global-size-4-xl); width: auto; - font: ${({ $size }) => calendarSelectSizeConfig[$size].controlFont}; + font: var(--global-font-static-comp-medium-s); color: inherit; text-align: left; @@ -70,18 +61,18 @@ export const StyledSelect = styled.select<{ align-items: center; gap: 0; box-sizing: border-box; - height: ${({ $size }) => buttonSizeConfig[$size].height}; - min-height: ${({ $size }) => buttonSizeConfig[$size].height}; + height: var(--global-size-s); + min-height: var(--global-size-s); padding: var(--global-space-none); padding-inline-end: var(--global-space-comp-m); - font: ${({ $size }) => calendarSelectSizeConfig[$size].optionFont}; + font: var(--global-font-static-comp-regular-s); background: var(--popover-bg-default); color: var(--input-dropdown-label-default); text-align: left; } option:checked { - color: var(--input-dropdown-label-default); + color: var(--input-dropdown-label-alt); } option:disabled { @@ -128,8 +119,8 @@ export const StyledSelect = styled.select<{ align-items: center; justify-content: center; flex-shrink: 0; - width: ${({ $size }) => calendarSelectSizeConfig[$size].checkmarkWidth}; - height: 20px; + width: var(--global-size-2-xs); + height: var(--global-size-2-xs); margin: 0; color: var(--input-dropdown-label-alt); font-family: CarbonIcons, sans-serif; @@ -157,13 +148,9 @@ export const StyledSelect = styled.select<{ } `; -export const StyledIcon = styled.span<{ - $size: CalendarSelectSize; - $disabled?: boolean; -}>` +export const StyledIcon = styled.span<{ $disabled?: boolean }>` position: absolute; - inset-inline-end: ${({ $size }) => - calendarSelectSizeConfig[$size].inlinePadding}; + inset-inline-end: var(--global-space-comp-m); z-index: 3; display: flex; align-items: center; diff --git a/src/components/date/__internal__/calendar/calendar-weekday.component.tsx b/src/components/date/__internal__/calendar/calendar-weekday.component.tsx index dfa588bd2e..d32b663090 100644 --- a/src/components/date/__internal__/calendar/calendar-weekday.component.tsx +++ b/src/components/date/__internal__/calendar/calendar-weekday.component.tsx @@ -18,13 +18,7 @@ const CalendarWeekday = ({ }: CalendarWeekdayProps) => ( diff --git a/src/components/date/__internal__/calendar/calendar-weekday.style.ts b/src/components/date/__internal__/calendar/calendar-weekday.style.ts index b0eb173804..34d689faa8 100644 --- a/src/components/date/__internal__/calendar/calendar-weekday.style.ts +++ b/src/components/date/__internal__/calendar/calendar-weekday.style.ts @@ -24,6 +24,7 @@ export const StyledWeekdayText = styled.span` export const StyledWeekdayTypography = styled(Typography)` && { + font: var(--global-font-static-comp-medium-s); color: var(--input-calendar-txt-alt); } `; diff --git a/src/components/date/__internal__/calendar/calendar.test.tsx b/src/components/date/__internal__/calendar/calendar.test.tsx index d71486e438..3105428bff 100644 --- a/src/components/date/__internal__/calendar/calendar.test.tsx +++ b/src/components/date/__internal__/calendar/calendar.test.tsx @@ -31,7 +31,6 @@ test("falls back to numeric month labels without a localizer", () => { monthSelectId="month" yearSelectId="year" years={[2025]} - size="medium" onMonthChange={noop} onYearChange={noop} />, diff --git a/src/components/date/__internal__/date-picker/date-picker.component.tsx b/src/components/date/__internal__/date-picker/date-picker.component.tsx index 2cc69ddf84..5fdf8a15c7 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -41,7 +41,6 @@ export const DatePicker = ({ ariaLabel: datePickerAriaLabel, ariaLabelledBy: datePickerAriaLabelledBy, pickerId, - size = "medium", }: DatePickerProps) => { if (!deprecateDisablePortalWarnTriggered && disablePortal) { deprecateDisablePortalWarnTriggered = true; @@ -182,7 +181,6 @@ export const DatePicker = ({ monthSelectId={monthSelectId} yearSelectId={yearSelectId} years={years} - size={size} localize={localize} onMonthChange={handleMonthChange} onYearChange={handleYearChange} diff --git a/src/components/date/__internal__/date-picker/date-picker.style.ts b/src/components/date/__internal__/date-picker/date-picker.style.ts index 2636e18faf..7a4de975ba 100644 --- a/src/components/date/__internal__/date-picker/date-picker.style.ts +++ b/src/components/date/__internal__/date-picker/date-picker.style.ts @@ -270,11 +270,11 @@ const DatePickerPopover = styled.div.attrs(applyBaseTheme)` position: absolute; z-index: 2; left: 50%; - bottom: calc(var(--global-size-4-xs) - var(--global-borderwidth-s)); + bottom: var(--global-space-comp-s); transform: translateX(-50%); width: var(--global-size-3-xs); height: var(--global-size-5-xs); - background: var(--input-calendar-bg-active); + background: var(--input-calendar-txt-default); border-radius: var(--global-radius-action-circle); pointer-events: none; } From 050c52f039c4e7df9929b129cc02e8fda0ec9256 Mon Sep 17 00:00:00 2001 From: iryna Date: Fri, 14 Aug 2026 13:58:19 +0300 Subject: [PATCH 04/12] feat(date): fix test --- .../date/__internal__/hooks/useDatePickerKeyboardNavigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.ts b/src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.ts index caa264389f..6d51d1c0d9 100644 --- a/src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.ts +++ b/src/components/date/__internal__/hooks/useDatePickerKeyboardNavigation.ts @@ -112,8 +112,8 @@ const useDatePickerKeyboardNavigation = ({ const handleKeyUp = useCallback( (event: React.KeyboardEvent) => { if (open && Events.isEscKey(event)) { - onEscape(); event.stopPropagation(); + onEscape(); } }, [onEscape, open], From 01c4bfbd96238ec8873e81fa8ba1b98ae4a8b5e0 Mon Sep 17 00:00:00 2001 From: iryna Date: Thu, 20 Aug 2026 18:16:09 +0300 Subject: [PATCH 05/12] feat(date): update calendar dialog and trigger accessibility --- skills/carbon-react/components/date-input.md | 4 +- skills/carbon-react/components/date-range.md | 90 +++++++------- .../date-range/date-range.component.tsx | 20 ++- src/components/date-range/date-range.test.tsx | 50 -------- .../date-picker-trigger.component.tsx | 11 +- .../date-picker-trigger.style.ts | 6 + .../date-picker-trigger.test.tsx | 115 ++++++++++++++++++ .../date-picker/date-picker.component.tsx | 17 +-- .../date-picker/date-picker.test.tsx | 22 +--- .../date-picker/date-picker.types.ts | 4 - .../hooks/useDatePickerAccessibility.test.ts | 61 ---------- .../hooks/useDatePickerAccessibility.ts | 26 ---- .../hooks/useDatePickerLocale.test.ts | 2 + .../__internal__/hooks/useDatePickerLocale.ts | 1 + src/components/date/date-typical.test.tsx | 36 +----- src/components/date/date.component.tsx | 16 ++- src/locales/en-gb.ts | 2 + src/locales/locale.ts | 4 + 18 files changed, 224 insertions(+), 263 deletions(-) create mode 100644 src/components/date/__internal__/date-picker-trigger/date-picker-trigger.test.tsx delete mode 100644 src/components/date/__internal__/hooks/useDatePickerAccessibility.test.ts delete mode 100644 src/components/date/__internal__/hooks/useDatePickerAccessibility.ts diff --git a/skills/carbon-react/components/date-input.md b/skills/carbon-react/components/date-input.md index 6c1d5afee0..207e31ae2e 100644 --- a/skills/carbon-react/components/date-input.md +++ b/skills/carbon-react/components/date-input.md @@ -39,8 +39,6 @@ description: Carbon DateInput component props and usage examples. | dangerouslySetInnerHTML | { __html: string \| TrustedHTML; } \| undefined | No | | | | | | | datatype | string \| undefined | No | | | | | | | dateFormatOverride | string \| undefined | No | | | | Date format string to be applied to the date inputs | | -| datePickerAriaLabel | string \| undefined | No | | | | Prop to specify the aria-label attribute of the date picker | | -| datePickerAriaLabelledBy | string \| undefined | No | | | | Prop to specify the aria-labelledby attribute of the date picker | | | defaultChecked | boolean \| undefined | No | | | | | | | dir | string \| undefined | No | | | | | | | disabled | boolean \| undefined | No | | | | If true, the component will be disabled | | @@ -341,6 +339,8 @@ description: Carbon DateInput component props and usage examples. | aria-valuenow | number \| undefined | No | | | | Defines the current value for a range widget. | | | aria-valuetext | string \| undefined | No | | | | Defines the human readable text alternative of aria-valuenow for a range widget. | | | adaptiveLabelBreakpoint | number \| undefined | No | | Yes | `adaptiveLabelBreakpoint` has been deprecated, the functionality will no longer work. | | | +| datePickerAriaLabel | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | +| datePickerAriaLabelledBy | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | | fieldHelp | React.ReactNode | No | | Yes | `fieldHelp` has been deprecated, `inputHint` should be used instead. [Legacy] Help content to be displayed under an input. | | | | helpAriaLabel | string \| undefined | No | | Yes | `helpAriaLabel` has been deprecated, the functionality will no longer work. | | | | info | string \| boolean \| undefined | No | | Yes | `info` has been deprecated, the functionality will no longer work. | | | diff --git a/skills/carbon-react/components/date-range.md b/skills/carbon-react/components/date-range.md index df6fa438cf..20e008b368 100644 --- a/skills/carbon-react/components/date-range.md +++ b/skills/carbon-react/components/date-range.md @@ -13,51 +13,51 @@ description: Carbon DateRange component props and usage examples. - Props interface: `DateRangeProps` ## Props -| Name | Type | Required | Literals | Description | Default | -| --- | --- | --- | --- | --- | --- | -| onChange | (ev: DateRangeChangeEvent) => void | Yes | | Specify a callback triggered on change | | -| value | string[] | Yes | | An array containing the value of startDate and endDate | | -| dateFormatOverride | string \| undefined | No | | Date format string to be applied to the date inputs | | -| datePickerEndAriaLabel | string \| undefined | No | | Prop to specify the aria-label attribute of the end date picker | | -| datePickerEndAriaLabelledBy | string \| undefined | No | | Prop to specify the aria-labelledby attribute of the end date picker | | -| datePickerStartAriaLabel | string \| undefined | No | | Prop to specify the aria-label attribute of the start date picker | | -| datePickerStartAriaLabelledBy | string \| undefined | No | | Prop to specify the aria-labelledby attribute of the start date picker | | -| endDateProps | Omit, "required"> \| undefined | No | | Props for the child end Date component | {} | -| endError | string \| boolean \| undefined | No | | Indicate that error has occurred on end date. Pass string to display icon, tooltip and red border. Pass true boolean to only display red border. | | -| endInfo | string \| boolean \| undefined | No | | [Legacy] Indicate additional information for end date. Pass string to display icon, tooltip and blue border. Pass true boolean to only display blue border. | | -| endLabel | string \| undefined | No | | Optional label for endDate field | | -| endRef | React.ForwardedRef \| undefined | No | | A React ref to pass to the second of the two Date Input fields | | -| endWarning | string \| boolean \| undefined | No | | Indicate that warning has occurred on end date. Pass string to display icon, tooltip and orange border. Pass true boolean to only display orange border. | | -| id | string \| undefined | No | | An optional string prop to provide an id to the component | | -| labelsInline | boolean \| undefined | No | | [Legacy] Display labels inline | | -| m | ResponsiveValue \| undefined | No | | Margin on top, left, bottom and right | | -| margin | ResponsiveValue \| undefined | No | | Margin on top, left, bottom and right | | -| marginBottom | ResponsiveValue \| undefined | No | | Margin on bottom | | -| marginLeft | ResponsiveValue \| undefined | No | | Margin on left | | -| marginRight | ResponsiveValue \| undefined | No | | Margin on right | | -| marginTop | ResponsiveValue \| undefined | No | | Margin on top | | -| marginX | ResponsiveValue \| undefined | No | | Margin on left and right | | -| marginY | ResponsiveValue \| undefined | No | | Margin on top and bottom | | -| mb | ResponsiveValue \| undefined | No | | Margin on bottom | | -| ml | ResponsiveValue \| undefined | No | | Margin on left | | -| mr | ResponsiveValue \| undefined | No | | Margin on right | | -| mt | ResponsiveValue \| undefined | No | | Margin on top | | -| mx | ResponsiveValue \| undefined | No | | Margin on left and right | | -| my | ResponsiveValue \| undefined | No | | Margin on top and bottom | | -| name | string \| undefined | No | | An optional string prop to provide a name to the component | | -| onBlur | ((ev: DateRangeChangeEvent) => void) \| undefined | No | | Specify a callback triggered on blur | | -| required | boolean \| undefined | No | | Flag to configure component as mandatory. | | -| startDateProps | Omit, "required"> \| undefined | No | | Props for the child start Date component | {} | -| startError | string \| boolean \| undefined | No | | Indicate that error has occurred on start date. Pass string to display icon, tooltip and red border. Pass true boolean to only display red border. | | -| startInfo | string \| boolean \| undefined | No | | [Legacy] Indicate additional information for start date. Pass string to display icon, tooltip and blue border. Pass true boolean to only display blue border. | | -| startLabel | string \| undefined | No | | Optional label for startDate field | | -| startRef | React.ForwardedRef \| undefined | No | | A React ref to pass to the first of the two Date Input fields | | -| startWarning | string \| boolean \| undefined | No | | Indicate that warning has occurred on start date. Pass string to display icon, tooltip and orange border. Pass true boolean to only display orange border. | | -| tooltipPosition | "left" \| "right" \| "bottom" \| "top" \| undefined | No | | [Legacy] Overrides the default tooltip position | | -| validationMessagePositionTop | boolean \| undefined | No | | Render the ValidationMessage above the Date inputs when validationRedesignOptIn flag is set | true | -| validationOnLabel | boolean \| undefined | No | | [Legacy] When true, validation icons will be placed on labels instead of being placed on the inputs | | -| data-element | string \| undefined | No | | Identifier used for testing purposes, applied to the root element of the component. | | -| data-role | string \| undefined | No | | Identifier used for testing purposes, applied to the root element of the component. | | +| Name | Type | Required | Literals | Deprecated | Deprecation reason | Description | Default | +| --- | --- | --- | --- | --- | --- | --- | --- | +| onChange | (ev: DateRangeChangeEvent) => void | Yes | | | | Specify a callback triggered on change | | +| value | string[] | Yes | | | | An array containing the value of startDate and endDate | | +| dateFormatOverride | string \| undefined | No | | | | Date format string to be applied to the date inputs | | +| endDateProps | Omit, "required"> \| undefined | No | | | | Props for the child end Date component | {} | +| endError | string \| boolean \| undefined | No | | | | Indicate that error has occurred on end date. Pass string to display icon, tooltip and red border. Pass true boolean to only display red border. | | +| endInfo | string \| boolean \| undefined | No | | | | [Legacy] Indicate additional information for end date. Pass string to display icon, tooltip and blue border. Pass true boolean to only display blue border. | | +| endLabel | string \| undefined | No | | | | Optional label for endDate field | | +| endRef | React.ForwardedRef \| undefined | No | | | | A React ref to pass to the second of the two Date Input fields | | +| endWarning | string \| boolean \| undefined | No | | | | Indicate that warning has occurred on end date. Pass string to display icon, tooltip and orange border. Pass true boolean to only display orange border. | | +| id | string \| undefined | No | | | | An optional string prop to provide an id to the component | | +| labelsInline | boolean \| undefined | No | | | | [Legacy] Display labels inline | | +| m | ResponsiveValue \| undefined | No | | | | Margin on top, left, bottom and right | | +| margin | ResponsiveValue \| undefined | No | | | | Margin on top, left, bottom and right | | +| marginBottom | ResponsiveValue \| undefined | No | | | | Margin on bottom | | +| marginLeft | ResponsiveValue \| undefined | No | | | | Margin on left | | +| marginRight | ResponsiveValue \| undefined | No | | | | Margin on right | | +| marginTop | ResponsiveValue \| undefined | No | | | | Margin on top | | +| marginX | ResponsiveValue \| undefined | No | | | | Margin on left and right | | +| marginY | ResponsiveValue \| undefined | No | | | | Margin on top and bottom | | +| mb | ResponsiveValue \| undefined | No | | | | Margin on bottom | | +| ml | ResponsiveValue \| undefined | No | | | | Margin on left | | +| mr | ResponsiveValue \| undefined | No | | | | Margin on right | | +| mt | ResponsiveValue \| undefined | No | | | | Margin on top | | +| mx | ResponsiveValue \| undefined | No | | | | Margin on left and right | | +| my | ResponsiveValue \| undefined | No | | | | Margin on top and bottom | | +| name | string \| undefined | No | | | | An optional string prop to provide a name to the component | | +| onBlur | ((ev: DateRangeChangeEvent) => void) \| undefined | No | | | | Specify a callback triggered on blur | | +| required | boolean \| undefined | No | | | | Flag to configure component as mandatory. | | +| startDateProps | Omit, "required"> \| undefined | No | | | | Props for the child start Date component | {} | +| startError | string \| boolean \| undefined | No | | | | Indicate that error has occurred on start date. Pass string to display icon, tooltip and red border. Pass true boolean to only display red border. | | +| startInfo | string \| boolean \| undefined | No | | | | [Legacy] Indicate additional information for start date. Pass string to display icon, tooltip and blue border. Pass true boolean to only display blue border. | | +| startLabel | string \| undefined | No | | | | Optional label for startDate field | | +| startRef | React.ForwardedRef \| undefined | No | | | | A React ref to pass to the first of the two Date Input fields | | +| startWarning | string \| boolean \| undefined | No | | | | Indicate that warning has occurred on start date. Pass string to display icon, tooltip and orange border. Pass true boolean to only display orange border. | | +| tooltipPosition | "left" \| "right" \| "bottom" \| "top" \| undefined | No | | | | [Legacy] Overrides the default tooltip position | | +| validationMessagePositionTop | boolean \| undefined | No | | | | Render the ValidationMessage above the Date inputs when validationRedesignOptIn flag is set | true | +| validationOnLabel | boolean \| undefined | No | | | | [Legacy] When true, validation icons will be placed on labels instead of being placed on the inputs | | +| data-element | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | +| data-role | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | +| datePickerEndAriaLabel | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | +| datePickerEndAriaLabelledBy | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | +| datePickerStartAriaLabel | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | +| datePickerStartAriaLabelledBy | string \| undefined | No | | Yes | The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). Remove this prop; it has no effect. | | | ## Examples ### Default diff --git a/src/components/date-range/date-range.component.tsx b/src/components/date-range/date-range.component.tsx index a64a333535..4d0fed9396 100644 --- a/src/components/date-range/date-range.component.tsx +++ b/src/components/date-range/date-range.component.tsx @@ -116,13 +116,25 @@ export interface DateRangeProps required?: boolean; /** Date format string to be applied to the date inputs */ dateFormatOverride?: string; - /** Prop to specify the aria-label attribute of the start date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerStartAriaLabel?: string; - /** Prop to specify the aria-labelledby attribute of the start date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerStartAriaLabelledBy?: string; - /** Prop to specify the aria-label attribute of the end date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerEndAriaLabel?: string; - /** Prop to specify the aria-labelledby attribute of the end date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerEndAriaLabelledBy?: string; /** Render the ValidationMessage above the Date inputs when validationRedesignOptIn flag is set */ validationMessagePositionTop?: boolean; diff --git a/src/components/date-range/date-range.test.tsx b/src/components/date-range/date-range.test.tsx index e5dfa54cdb..1903458fdc 100644 --- a/src/components/date-range/date-range.test.tsx +++ b/src/components/date-range/date-range.test.tsx @@ -684,56 +684,6 @@ test("should close the open 'start' picker when the user moves focus to the `end expect(within(startDate).queryByRole("grid")).not.toBeInTheDocument(); }); -test("should apply aria-label and aria-labelledby to the date picker dialog", async () => { - const user = userEvent.setup(); - render( - {}} - startDateProps={{ disablePortal: true, "data-role": "start" }} - endDateProps={{ disablePortal: true, "data-role": "end" }} - datePickerStartAriaLabel="start aria-label" - datePickerStartAriaLabelledBy="start aria-labelledby" - datePickerEndAriaLabel="end aria-label" - datePickerEndAriaLabelledBy="end aria-labelledby" - />, - ); - const startDate = screen.getByTestId("start"); - const endDate = screen.getByTestId("end"); - - const startCalendarButton = within(startDate).getByRole("button", { - name: "Open calendar", - }); - await user.click(startCalendarButton); - - expect(within(startDate).getByRole("dialog")).toBeVisible(); - expect(within(startDate).getByRole("dialog")).toHaveAttribute( - "aria-label", - "start aria-label", - ); - expect(within(startDate).getByRole("dialog")).toHaveAttribute( - "aria-labelledby", - expect.stringContaining("start aria-labelledby"), - ); - - const endCalendarButton = within(endDate).getByRole("button", { - name: "Open calendar", - }); - await user.click(endCalendarButton); - - expect(within(endDate).getByRole("dialog")).toBeVisible(); - expect(within(endDate).getByRole("dialog")).toHaveAttribute( - "aria-label", - "end aria-label", - ); - expect(within(endDate).getByRole("dialog")).toHaveAttribute( - "aria-labelledby", - expect.stringContaining("end aria-labelledby"), - ); -}); - test("should close the open 'end' picker when the user moves focus to the `start` input", async () => { const user = userEvent.setup(); render( diff --git a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.component.tsx b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.component.tsx index 61ff64e0b1..49bfa90923 100644 --- a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.component.tsx +++ b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.component.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { DatePickerTriggerButton, @@ -6,9 +6,11 @@ import { DatePickerTriggerDivider, DatePickerTriggerDividerWrapper, LegacyDatePickerTrigger, + DatePickerTriggerDescription, } from "./date-picker-trigger.style"; import Icon from "../../../icon"; import useLocale from "../../../../hooks/__internal__/useLocale"; +import guid from "../../../../__internal__/utils/helpers/guid"; interface DatePickerTriggerProps { disabled?: boolean; @@ -32,6 +34,9 @@ const DatePickerTrigger = ({ onMouseDown, }: DatePickerTriggerProps) => { const locale = useLocale(); + const [triggerDescriptionId] = useState( + () => `date-picker-trigger-description-${guid()}`, + ); if (variant === "legacy") { return ( @@ -66,6 +71,7 @@ const DatePickerTrigger = ({ onClick={onClick} disabled={disabled || readOnly} aria-label={locale.date.ariaLabels.openCalendarButton?.()} + aria-describedby={triggerDescriptionId} aria-haspopup="dialog" aria-controls={open ? pickerId : undefined} aria-expanded={open} @@ -77,6 +83,9 @@ const DatePickerTrigger = ({ iconType="calendar_today" m={0} /> + + {locale.date.ariaLabels.openCalendarDescription?.()} + ); }; diff --git a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts index 5494328770..f7b16b7dcb 100644 --- a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts +++ b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts @@ -6,6 +6,7 @@ import { propsForSize as buttonSizeConfig } from "../../../button/__next__/butto import addFocusStyling from "../../../../style/utils/add-focus-styling"; import type { DatePickerSize } from "../date-picker"; import legacyInputSizes from "../../../../__internal__/legacy-input/input-sizes.style"; +import visuallyHidden from "../../../../style/utils/visually-hidden"; const DatePickerTriggerContainer = styled.span` align-self: stretch; @@ -148,10 +149,15 @@ const DatePickerTriggerButton = styled(Button)` } `; +const DatePickerTriggerDescription = styled.span` + ${visuallyHidden} +`; + export { DatePickerTriggerContainer, DatePickerTriggerDividerWrapper, DatePickerTriggerDivider, DatePickerTriggerButton, LegacyDatePickerTrigger, + DatePickerTriggerDescription, }; diff --git a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.test.tsx b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.test.tsx new file mode 100644 index 0000000000..e822c6b4b3 --- /dev/null +++ b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { enGB as enGBDateLocale } from "date-fns/locale/en-GB"; + +import I18nProvider from "../../../i18n-provider"; +import DatePickerTrigger from "./date-picker-trigger.component"; + +const defaultProps = { + pickerId: "test-picker", + size: "medium" as const, + onClick: () => {}, +}; + +describe("typical variant", () => { + test("renders a visually hidden span in the DOM", () => { + render(); + + const span = screen.getByText("Instructions on how to use", { + selector: "span", + }); + + expect(span).toBeInTheDocument(); + }); + + test("aria-describedby on button matches hidden span id", () => { + render(); + + const button = screen.getByRole("button", { name: "Open calendar" }); + const span = screen.getByText("Instructions on how to use", { + selector: "span", + }); + + const descId = button.getAttribute("aria-describedby"); + expect(descId).toBeTruthy(); + + expect(span).toHaveAttribute("id", descId); + }); + + test("hidden span text matches locale openCalendarDescription", () => { + render(); + + const span = screen.getByText("Instructions on how to use", { + selector: "span", + }); + + expect(span).toHaveTextContent("Instructions on how to use"); + }); + + test("hidden span text uses locale override when provided", () => { + render( + enGBDateLocale, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + openCalendarDescription: () => "Custom instructions", + }, + }, + }} + > + + , + ); + + const span = screen.getByText("Custom instructions", { + selector: "span", + }); + + expect(span).toHaveTextContent("Custom instructions"); + }); + + test("hidden span is not hidden with display:none or visibility:hidden", () => { + render(); + + const span = screen.getByText("Instructions on how to use", { + selector: "span", + }); + + expect(span).not.toHaveStyle("display: none"); + expect(span).not.toHaveStyle("visibility: hidden"); + }); + + test("stable ID — does not change on re-render", () => { + const { rerender } = render( + , + ); + + const button = screen.getByRole("button", { name: "Open calendar" }); + const span = screen.getByText("Instructions on how to use", { + selector: "span", + }); + + const idBefore = button.getAttribute("aria-describedby"); + + rerender(); + + expect(button.getAttribute("aria-describedby")).toBe(idBefore); + expect(span).toHaveAttribute("id", idBefore); + }); +}); + +describe("legacy variant", () => { + test("does not render a hidden instructions span", () => { + render(); + + // No button element — legacy trigger is aria-hidden + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + // No instructions text + expect( + screen.queryByText("Instructions on how to use"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/date/__internal__/date-picker/date-picker.component.tsx b/src/components/date/__internal__/date-picker/date-picker.component.tsx index 5fdf8a15c7..a3c6a789a1 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -9,7 +9,6 @@ import Calendar from "../calendar/calendar.component"; import CalendarCloseButton from "../calendar/calendar-close-button.component"; import CalendarNavigation from "../calendar/calendar-navigation.component"; import type { DatePickerProps } from "./date-picker.types"; -import useDatePickerAccessibility from "../hooks/useDatePickerAccessibility"; import useDatePickerFocus from "../hooks/useDatePickerFocus"; import useDatePickerInFlatTable from "../hooks/useDatePickerInFlatTable"; import useDatePickerKeyboardNavigation from "../hooks/useDatePickerKeyboardNavigation"; @@ -38,8 +37,6 @@ export const DatePicker = ({ open, onRequestPickerClose, pickerTabGuardId, - ariaLabel: datePickerAriaLabel, - ariaLabelledBy: datePickerAriaLabelledBy, pickerId, }: DatePickerProps) => { if (!deprecateDisablePortalWarnTriggered && disablePortal) { @@ -53,6 +50,7 @@ export const DatePicker = ({ selectedDate || new Date(), ); const { + datePickerDialogAriaLabel, closeButtonLabel, localize, weekStartsOn, @@ -65,10 +63,6 @@ export const DatePicker = ({ monthSelectId: `date-picker-month-${guid()}`, yearSelectId: `date-picker-year-${guid()}`, })); - const defaultPickerAriaLabelledBy = `${monthSelectId} ${yearSelectId}`; - const pickerAriaLabelledBy = - datePickerAriaLabelledBy || - (datePickerAriaLabel ? undefined : defaultPickerAriaLabelledBy); const closePickerAndRestoreFocus = useCallback(() => { inputContainerRef.current?.querySelector("input")?.focus(); @@ -128,12 +122,6 @@ export const DatePicker = ({ getPickerDayFocusTarget, resetChangedSelector, }); - useDatePickerAccessibility({ - ref: pickerRef, - open, - focusedMonth: displayedMonth, - labelledBy: defaultPickerAriaLabelledBy, - }); const { isInFlatTable } = useDatePickerInFlatTable(open); if (!open) return null; @@ -160,8 +148,7 @@ export const DatePicker = ({ onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} role="dialog" - aria-label={datePickerAriaLabel} - aria-labelledby={pickerAriaLabelledBy} + aria-label={datePickerDialogAriaLabel} > {/* When portaled, redirects Tab focus from the input into the calendar. */} {!disablePortal && ( diff --git a/src/components/date/__internal__/date-picker/date-picker.test.tsx b/src/components/date/__internal__/date-picker/date-picker.test.tsx index 6b612e611f..10016cc196 100644 --- a/src/components/date/__internal__/date-picker/date-picker.test.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.test.tsx @@ -83,25 +83,15 @@ test("renders month and year selects instead of navigation buttons", () => { ).not.toBeInTheDocument(); }); -test("labels every grid in a multi-month picker", () => { +test("renders the calendar dialog with a static aria-label from locale", () => { render( - {}} - open - disablePortal - dayPickerProps={{ numberOfMonths: 2 }} - />, + {}} open disablePortal />, ); - const dialog = screen.getByRole("dialog"); - const labelledBy = dialog.getAttribute("aria-labelledby"); - - expect(screen.getAllByRole("grid")).toHaveLength(2); - screen - .getAllByRole("grid") - .forEach((grid) => - expect(grid).toHaveAttribute("aria-labelledby", labelledBy), - ); + expect(screen.getByRole("dialog")).toHaveAttribute( + "aria-label", + "Choose a date", + ); }); test("renders a Close button that closes the picker and refocuses the input", async () => { diff --git a/src/components/date/__internal__/date-picker/date-picker.types.ts b/src/components/date/__internal__/date-picker/date-picker.types.ts index e37cb145d9..9a946c9280 100644 --- a/src/components/date/__internal__/date-picker/date-picker.types.ts +++ b/src/components/date/__internal__/date-picker/date-picker.types.ts @@ -31,10 +31,6 @@ export interface DatePickerProps { onRequestPickerClose: () => void; /** Id passed to the tab guard element. */ pickerTabGuardId?: string; - /** aria-label for the date picker. */ - ariaLabel?: string; - /** aria-labelledby for the date picker. */ - ariaLabelledBy?: string; /** Id for the date picker container. */ pickerId?: string; /** Size of the associated date input. */ diff --git a/src/components/date/__internal__/hooks/useDatePickerAccessibility.test.ts b/src/components/date/__internal__/hooks/useDatePickerAccessibility.test.ts deleted file mode 100644 index 8234475655..0000000000 --- a/src/components/date/__internal__/hooks/useDatePickerAccessibility.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { renderHook, within } from "@testing-library/react"; - -import useDatePickerAccessibility from "./useDatePickerAccessibility"; - -const createPickerRef = () => { - const picker = document.createElement("div"); - picker.innerHTML = ` -
-
- `; - - return { - picker, - ref: { current: picker }, - }; -}; - -test("does not label grids while the picker is closed", () => { - const { picker, ref } = createPickerRef(); - - renderHook(() => - useDatePickerAccessibility({ - ref, - open: false, - focusedMonth: new Date(2025, 0, 1), - labelledBy: "month year", - }), - ); - - within(picker) - .getAllByRole("grid") - .forEach((grid) => { - expect(grid).not.toHaveAttribute("aria-labelledby"); - }); -}); - -test("labels every grid and reapplies the label when the focused month changes", () => { - const { picker, ref } = createPickerRef(); - const { rerender } = renderHook( - ({ focusedMonth }) => - useDatePickerAccessibility({ - ref, - open: true, - focusedMonth, - labelledBy: "month year", - }), - { initialProps: { focusedMonth: new Date(2025, 0, 1) } }, - ); - const grids = within(picker).getAllByRole("grid"); - - grids.forEach((grid) => { - expect(grid).toHaveAttribute("aria-labelledby", "month year"); - grid.removeAttribute("aria-labelledby"); - }); - - rerender({ focusedMonth: new Date(2025, 1, 1) }); - - grids.forEach((grid) => { - expect(grid).toHaveAttribute("aria-labelledby", "month year"); - }); -}); diff --git a/src/components/date/__internal__/hooks/useDatePickerAccessibility.ts b/src/components/date/__internal__/hooks/useDatePickerAccessibility.ts deleted file mode 100644 index b75e4c7755..0000000000 --- a/src/components/date/__internal__/hooks/useDatePickerAccessibility.ts +++ /dev/null @@ -1,26 +0,0 @@ -import React, { useEffect } from "react"; - -interface UseDatePickerAccessibilityProps { - ref: React.RefObject; - open?: boolean; - focusedMonth?: Date; - labelledBy: string; -} - -/** Labels each rendered calendar grid with the active month and year controls. */ -const useDatePickerAccessibility = ({ - ref, - open, - focusedMonth, - labelledBy, -}: UseDatePickerAccessibilityProps) => { - useEffect(() => { - if (!open) return; - - ref.current - ?.querySelectorAll('.rdp-month_grid, table[role="grid"]') - .forEach((grid) => grid.setAttribute("aria-labelledby", labelledBy)); - }, [focusedMonth, labelledBy, open, ref]); -}; - -export default useDatePickerAccessibility; diff --git a/src/components/date/__internal__/hooks/useDatePickerLocale.test.ts b/src/components/date/__internal__/hooks/useDatePickerLocale.test.ts index e810a4e41f..39961fa2c2 100644 --- a/src/components/date/__internal__/hooks/useDatePickerLocale.test.ts +++ b/src/components/date/__internal__/hooks/useDatePickerLocale.test.ts @@ -15,6 +15,7 @@ const mockLocale = (dateFnsLocale: typeof enGB | typeof de | typeof pl) => { dateFnsLocale: () => dateFnsLocale, ariaLabels: { closeButton: () => "Close picker", + chooseDate: () => "Choose a date", }, }, } as ReturnType); @@ -25,6 +26,7 @@ test("provides all locale values needed by the date picker", () => { const { result } = renderHook(() => useDatePickerLocale()); + expect(result.current.datePickerDialogAriaLabel).toBe("Choose a date"); expect(result.current.closeButtonLabel).toBe("Close picker"); expect(result.current.localize).toBe(enGB.localize); expect(result.current.weekStartsOn).toBe(1); diff --git a/src/components/date/__internal__/hooks/useDatePickerLocale.ts b/src/components/date/__internal__/hooks/useDatePickerLocale.ts index 23eee79e7d..922613cf57 100644 --- a/src/components/date/__internal__/hooks/useDatePickerLocale.ts +++ b/src/components/date/__internal__/hooks/useDatePickerLocale.ts @@ -27,6 +27,7 @@ const useDatePickerLocale = () => { }, [localeLanguage, localize]); return { + datePickerDialogAriaLabel: locale.date.ariaLabels.chooseDate?.(), closeButtonLabel: locale.date.ariaLabels.closeButton?.(), localize, weekStartsOn: options?.weekStartsOn, diff --git a/src/components/date/date-typical.test.tsx b/src/components/date/date-typical.test.tsx index 546bdb879c..feaca08841 100644 --- a/src/components/date/date-typical.test.tsx +++ b/src/components/date/date-typical.test.tsx @@ -299,7 +299,7 @@ test("should open picker and focus a day when calendar button is clicked", async }); }); -test("should wire dialog and calendar labelling to month and year selectors", async () => { +test("should label the dialog with a static locale-derived aria-label", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render( { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - render( - {}} - value="" - />, - ); + expect(dialog).toHaveAttribute("aria-label", "Choose a date"); - await user.click(screen.getByRole("button", { name: "Open calendar" })); - - expect( - screen.getByRole("dialog", { name: "Choose a date" }), - ).not.toHaveAttribute("aria-labelledby"); + within(dialog).getByRole("grid"); }); test("should render date buttons with type button and hidden full date text", async () => { diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index c0fcebf38c..a0978d8071 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -94,9 +94,15 @@ export interface DateInputProps onPickerClose?: () => void; /** Date format string to be applied to the date inputs */ dateFormatOverride?: string; - /** Prop to specify the aria-label attribute of the date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerAriaLabel?: string; - /** Prop to specify the aria-labelledby attribute of the date picker */ + /** + * @deprecated The dialog label is now always derived from the locale (`date.ariaLabels.chooseDate`). + * Remove this prop; it has no effect. + */ datePickerAriaLabelledBy?: string; /** Date input presentation. Typical is the default; legacy retains its icon trigger. */ variant?: "legacy" | "typical"; @@ -149,8 +155,8 @@ export const DateInput = React.forwardRef( onPickerClose, onPickerOpen, dateFormatOverride: dateFormatOverrideProp, - datePickerAriaLabel, - datePickerAriaLabelledBy, + datePickerAriaLabel: _datePickerAriaLabel, + datePickerAriaLabelledBy: _datePickerAriaLabelledBy, validationMessagePositionTop = true, validationIconId: _validationIconId, validationOnLabel: _validationOnLabel, @@ -452,8 +458,6 @@ export const DateInput = React.forwardRef( open={open} onRequestPickerClose={closePicker} pickerTabGuardId={pickerTabGuardId} - ariaLabel={datePickerAriaLabel} - ariaLabelledBy={datePickerAriaLabelledBy} pickerId={pickerId} size={actualSize} /> diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts index e2040c66d5..a1b91f22b9 100644 --- a/src/locales/en-gb.ts +++ b/src/locales/en-gb.ts @@ -50,6 +50,8 @@ const enGB: Locale = { chooseMonth: () => "Choose the month", chooseYear: () => "Choose the year", closeButton: () => "Close", + openCalendarDescription: () => "Instructions on how to use", + chooseDate: () => "Choose a date", }, dateFormatOverride: undefined, }, diff --git a/src/locales/locale.ts b/src/locales/locale.ts index a41227b487..534d355ab8 100644 --- a/src/locales/locale.ts +++ b/src/locales/locale.ts @@ -42,6 +42,10 @@ interface Locale { chooseYear?: () => string; /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ closeButton?: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + openCalendarDescription?: () => string; + /** Optional in locale overrides; I18nProvider supplies the en-GB fallback. */ + chooseDate?: () => string; }; dateFormatOverride?: string; }; From 14dbb1f2d0fc11e574cfe0160dff658068c87f37 Mon Sep 17 00:00:00 2001 From: iryna Date: Thu, 20 Aug 2026 18:35:43 +0300 Subject: [PATCH 06/12] feat(date): fix tests --- src/components/date/date.pw.tsx | 40 ++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/components/date/date.pw.tsx b/src/components/date/date.pw.tsx index 1355cb9062..b5aee71bc4 100644 --- a/src/components/date/date.pw.tsx +++ b/src/components/date/date.pw.tsx @@ -223,12 +223,11 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page - .getByRole("combobox", { name: "Choose the month" }) - .selectOption({ - label: "June", - }); - await expect(page.getByRole("dialog", { name: NEXT_MONTH })).toBeVisible(); + const monthSelector = page.getByRole("combobox", { + name: "Choose the month", + }); + await monthSelector.selectOption({ label: "June" }); + await expect(monthSelector).toHaveValue("5"); }); test("should use the year selector to show a different year", async ({ @@ -243,10 +242,11 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page.getByRole("combobox", { name: "Choose the year" }).selectOption({ - label: "2023", + const yearSelector = page.getByRole("combobox", { + name: "Choose the year", }); - await expect(page.getByRole("dialog", { name: "May 2023" })).toBeVisible(); + await yearSelector.selectOption({ label: "2023" }); + await expect(yearSelector).toHaveValue("2023"); }); test(`should allow a user to tab into the picker and through its controls`, async ({ @@ -362,7 +362,7 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page.getByLabel("Date").press("Tab"); + await page.getByLabel("Date", { exact: true }).press("Tab"); const datePicker = page.getByTestId("date-picker"); await datePicker.waitFor(); @@ -380,7 +380,7 @@ test.describe("Functionality tests", () => { await expect(day31Button).toBeFocused(); await expect( - page.getByRole("dialog", { name: "March 2022" }), + page.getByRole("dialog", { name: "Choose a date" }), ).toBeVisible(); }); @@ -392,7 +392,7 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page.getByLabel("Date").press("Tab"); + await page.getByLabel("Date", { exact: true }).press("Tab"); const datePicker = page.getByTestId("date-picker"); await datePicker.waitFor(); @@ -410,7 +410,7 @@ test.describe("Functionality tests", () => { await expect(day28Button).toBeFocused(); await expect( - page.getByRole("dialog", { name: "March 2022" }), + page.getByRole("dialog", { name: "Choose a date" }), ).toBeVisible(); }); @@ -422,7 +422,7 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page.getByLabel("Date").press("Tab"); + await page.getByLabel("Date", { exact: true }).press("Tab"); const datePicker = page.getByTestId("date-picker"); await datePicker.waitFor(); @@ -439,7 +439,9 @@ test.describe("Functionality tests", () => { await expect(day1Button).toBeFocused(); - await expect(page.getByRole("dialog", { name: "May 2022" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Choose a date" }), + ).toBeVisible(); }); ["Enter", "Space"].forEach((key) => { @@ -452,7 +454,7 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - const dateInput = page.getByLabel("Date"); + const dateInput = page.getByLabel("Date", { exact: true }); await dateInput.press("Tab"); const datePicker = page.getByTestId("date-picker"); @@ -476,7 +478,7 @@ test.describe("Functionality tests", () => { const calendarIcon = page.getByTestId("icon"); await calendarIcon.click(); - await page.getByLabel("Date").press("Tab"); + await page.getByLabel("Date", { exact: true }).press("Tab"); const datePicker = page.getByTestId("date-picker"); await datePicker.waitFor(); @@ -493,7 +495,9 @@ test.describe("Functionality tests", () => { await expect(day1Button).toBeFocused(); - await expect(page.getByRole("dialog", { name: "June 2022" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Choose a date" }), + ).toBeVisible(); }); test(`when maxWidth has no value it should render as 100%`, async ({ From 63d559bd1969e9ca03704f873c5371dc454210a7 Mon Sep 17 00:00:00 2001 From: iryna Date: Fri, 28 Aug 2026 11:29:16 +0300 Subject: [PATCH 07/12] feat(date): limit select option to max 12 items. fix trigger active style --- .../calendar-select/calendar-select.style.ts | 13 ++++++++ .../date-picker-trigger.style.ts | 9 ++++++ .../date-picker/date-picker.style.ts | 2 +- src/components/date/date.pw.tsx | 31 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts index b2731d075c..4235d90d56 100644 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts +++ b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts @@ -1,6 +1,8 @@ import styled, { css } from "styled-components"; import addFocusStyling from "../../../../../style/utils/add-focus-styling"; +const MAX_VISIBLE_OPTIONS = 12; + export const StyledWrapper = styled.div<{ $disabled?: boolean }>` position: relative; display: inline-flex; @@ -102,12 +104,23 @@ export const StyledSelect = styled.select` } &::picker(select) { + --calendar-select-option-block-size: var(--global-size-s); + --calendar-select-picker-padding-block: var(--global-space-comp-xs); + box-sizing: border-box; padding: var(--global-space-comp-xs) 0; background: var(--popover-bg-default); border: none; border-radius: var(--global-radius-container-m); box-shadow: var(--global-depth-lvl1); + /* Limit the picker to 12 option rows and include its block padding so + * the final visible row is not clipped. */ + max-block-size: calc( + ${MAX_VISIBLE_OPTIONS} * var(--calendar-select-option-block-size) + 2 * + var(--calendar-select-picker-padding-block) + ); + overflow-y: auto; + overscroll-behavior: contain; } &::picker-icon { diff --git a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts index f7b16b7dcb..3721c2614b 100644 --- a/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts +++ b/src/components/date/__internal__/date-picker-trigger/date-picker-trigger.style.ts @@ -128,6 +128,15 @@ const DatePickerTriggerButton = styled(Button)` border: none; } + &&[aria-expanded="true"]:not(:disabled) { + background-color: var(--button-typical-subtle-bg-active); + color: var(--button-typical-subtle-label-active); + + ${StyledIcon} { + color: var(--button-typical-subtle-label-active); + } + } + &&:not(:disabled) { cursor: pointer; } diff --git a/src/components/date/__internal__/date-picker/date-picker.style.ts b/src/components/date/__internal__/date-picker/date-picker.style.ts index 7a4de975ba..6d999f0c05 100644 --- a/src/components/date/__internal__/date-picker/date-picker.style.ts +++ b/src/components/date/__internal__/date-picker/date-picker.style.ts @@ -24,7 +24,7 @@ const DatePickerPopover = styled.div.attrs(applyBaseTheme)` padding: var(--global-space-layout-2-xs); background: var(--popover-bg-default); box-shadow: var(--global-depth-lvl1); - border-radius: var(--global-radius-container-m); + border-radius: var(--global-radius-action-m); .rdp-root, .rdp-root * { diff --git a/src/components/date/date.pw.tsx b/src/components/date/date.pw.tsx index b5aee71bc4..fab15c1dca 100644 --- a/src/components/date/date.pw.tsx +++ b/src/components/date/date.pw.tsx @@ -249,6 +249,37 @@ test.describe("Functionality tests", () => { await expect(yearSelector).toHaveValue("2023"); }); + test("limits the customizable year picker to twelve visible options", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Open calendar" }).click(); + const yearSelector = page.getByRole("combobox", { + name: "Choose the year", + }); + + const pickerStyles = await yearSelector.evaluate((selector) => { + const picker = getComputedStyle(selector, "::picker(select)"); + return { + maxBlockSize: Number.parseFloat(picker.maxBlockSize), + optionBlockSize: Number.parseFloat( + picker.getPropertyValue("--calendar-select-option-block-size"), + ), + paddingBlock: Number.parseFloat( + picker.getPropertyValue("--calendar-select-picker-padding-block"), + ), + overflowY: picker.overflowY, + }; + }); + + expect(pickerStyles.maxBlockSize).toBe( + 12 * pickerStyles.optionBlockSize + 2 * pickerStyles.paddingBlock, + ); + expect(pickerStyles.overflowY).toBe("auto"); + }); + test(`should allow a user to tab into the picker and through its controls`, async ({ mount, page, From a90a54d85792d7d6b8a8eeabcf9de8866e31b50a Mon Sep 17 00:00:00 2001 From: iryna Date: Wed, 2 Sep 2026 11:55:04 +0300 Subject: [PATCH 08/12] feat(date): add calendar navigation test. add strory for disabled year/month selectors --- skills/carbon-react/components/date-input.md | 30 +++ .../calendar/calendar-navigation.test.tsx | 183 ++++++++++++++++++ .../calendar-select/calendar-select.style.ts | 11 +- src/components/date/date.stories.tsx | 27 +++ 4 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 src/components/date/__internal__/calendar/calendar-navigation.test.tsx diff --git a/skills/carbon-react/components/date-input.md b/skills/carbon-react/components/date-input.md index 207e31ae2e..cc9d53ff23 100644 --- a/skills/carbon-react/components/date-input.md +++ b/skills/carbon-react/components/date-input.md @@ -528,6 +528,36 @@ description: Carbon DateInput component props and usage examples. ``` +### Disabled Year and Month Selectors + +**Render** + +```tsx +() => { + return ( + + + The year and month selectors disable automatically when minDate and + maxDate narrow the selectable range: only June 2024 is selectable below, + so the year selector is disabled (only one year available) and every + month except June is disabled. + + + + ); +} +``` + + ### With Label Inline **Render** diff --git a/src/components/date/__internal__/calendar/calendar-navigation.test.tsx b/src/components/date/__internal__/calendar/calendar-navigation.test.tsx new file mode 100644 index 0000000000..63e86b446c --- /dev/null +++ b/src/components/date/__internal__/calendar/calendar-navigation.test.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { enUS as enUSLocale } from "date-fns/locale/en-US"; + +import CalendarNavigation from "./calendar-navigation.component"; + +const noop = () => {}; + +const getMonthSelect = () => + within(screen.getByTestId("date-picker-month-selector")).getByRole( + "combobox", + ); + +const getYearSelect = () => + within(screen.getByTestId("date-picker-year-selector")).getByRole("combobox"); + +test("year select is enabled when more than one year is selectable", () => { + render( + , + ); + + expect(getYearSelect()).toBeEnabled(); +}); + +test("year select is disabled when only one year is selectable", () => { + render( + , + ); + + expect(getYearSelect()).toBeDisabled(); +}); + +test("year select is disabled when no years are selectable", () => { + render( + , + ); + + expect(getYearSelect()).toBeDisabled(); +}); + +test("month select disables months before minMonth in the boundary year", () => { + render( + , + ); + + const monthSelect = getMonthSelect(); + expect(monthSelect).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "January" }), + ).toBeDisabled(); + expect( + within(monthSelect).getByRole("option", { name: "February" }), + ).toBeDisabled(); + expect( + within(monthSelect).getByRole("option", { name: "March" }), + ).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "June" }), + ).toBeEnabled(); +}); + +test("month select disables months after maxMonth in the boundary year", () => { + render( + , + ); + + const monthSelect = getMonthSelect(); + expect(monthSelect).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "August" }), + ).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "September" }), + ).toBeDisabled(); + expect( + within(monthSelect).getByRole("option", { name: "December" }), + ).toBeDisabled(); +}); + +test("minMonth/maxMonth bounds only apply to the year they fall in", () => { + render( + , + ); + + const monthSelect = getMonthSelect(); + expect(monthSelect).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "January" }), + ).toBeEnabled(); + expect( + within(monthSelect).getByRole("option", { name: "December" }), + ).toBeEnabled(); +}); + +test("month select is disabled entirely when every month is disabled", () => { + render( + , + ); + + expect(getMonthSelect()).toBeDisabled(); +}); + +test("calls onMonthChange and onYearChange when the user picks a value", async () => { + const user = userEvent.setup(); + const onMonthChange = jest.fn(); + const onYearChange = jest.fn(); + + render( + , + ); + + await user.selectOptions(getMonthSelect(), "7"); + expect(onMonthChange).toHaveBeenCalledTimes(1); + + await user.selectOptions(getYearSelect(), "2025"); + expect(onYearChange).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts index 4235d90d56..39aca51eee 100644 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts +++ b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts @@ -80,6 +80,7 @@ export const StyledSelect = styled.select` option:disabled { color: var(--input-dropdown-label-disabled); background: var(--popover-bg-default); + cursor: not-allowed; } @supports (appearance: base-select) { @@ -144,17 +145,17 @@ export const StyledSelect = styled.select` content: "\\f003"; } - option:hover, - option:focus { + option:not(:disabled):hover, + option:not(:disabled):focus { color: var(--input-dropdown-label-hover); } - option:hover { + option:not(:disabled):hover { background: var(--input-dropdown-bg-hover); } - option:focus, - option:focus-visible { + option:not(:disabled):focus, + option:not(:disabled):focus-visible { ${addFocusStyling(true)} color: var(--input-dropdown-label-hover); } diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index e034cdb051..d8f9a928e0 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -209,6 +209,33 @@ DisabledDatesInCalendar.parameters = { chromatic: { disableSnapshot: false }, }; +export const DisabledYearAndMonthSelectors: Story = () => { + return ( + + + The year and month selectors disable automatically when minDate and + maxDate narrow the selectable range: only June 2024 is selectable below, + so the year selector is disabled (only one year available) and every + month except June is disabled. + + + + ); +}; +DisabledYearAndMonthSelectors.storyName = "Disabled Year and Month Selectors"; +DisabledYearAndMonthSelectors.parameters = { + chromatic: { disableSnapshot: false }, +}; + export const WithLabelInline: Story = () => { return ; }; From 4ed947d067341d0cb9660c203714740953c1feb2 Mon Sep 17 00:00:00 2001 From: iryna Date: Fri, 4 Sep 2026 17:09:00 +0300 Subject: [PATCH 09/12] feat(date): remove year/month disabled styles. cleanup story --- skills/carbon-react/components/date-input.md | 16 ++-- .../calendar-navigation.component.tsx | 14 ---- .../calendar/calendar-navigation.test.tsx | 83 ++----------------- .../calendar-select/calendar-select.style.ts | 17 ++-- .../date-picker/date-picker.component.tsx | 26 ++---- .../date-picker/date-picker.utils.test.ts | 49 ++++++----- .../date-picker/date-picker.utils.ts | 50 +++-------- .../useDatePickerMonthYearSelection.test.ts | 40 +++++---- .../hooks/useDatePickerMonthYearSelection.ts | 6 +- src/components/date/date-typical.test.tsx | 9 +- src/components/date/date.stories.tsx | 21 ++--- 11 files changed, 103 insertions(+), 228 deletions(-) diff --git a/skills/carbon-react/components/date-input.md b/skills/carbon-react/components/date-input.md index cc9d53ff23..bfbf7c9711 100644 --- a/skills/carbon-react/components/date-input.md +++ b/skills/carbon-react/components/date-input.md @@ -528,7 +528,7 @@ description: Carbon DateInput component props and usage examples. ``` -### Disabled Year and Month Selectors +### Disabled Dates Outside Min/Max Range **Render** @@ -541,16 +541,16 @@ description: Carbon DateInput component props and usage examples. gap="var(--global-space-layout-xs)" > - The year and month selectors disable automatically when minDate and - maxDate narrow the selectable range: only June 2024 is selectable below, - so the year selector is disabled (only one year available) and every - month except June is disabled. + Days outside the minDate/maxDate range are disabled in the calendar + below: only dates from 10th to 20th June 2024 are selectable. The year + and month selectors themselves remain enabled so users can still + navigate to, and view, months outside the selectable range.
); diff --git a/src/components/date/__internal__/calendar/calendar-navigation.component.tsx b/src/components/date/__internal__/calendar/calendar-navigation.component.tsx index c0efc469ad..2cc4f578a9 100644 --- a/src/components/date/__internal__/calendar/calendar-navigation.component.tsx +++ b/src/components/date/__internal__/calendar/calendar-navigation.component.tsx @@ -7,8 +7,6 @@ import CalendarSelect from "./calendar-select"; interface CalendarNavigationProps { displayedMonth: Date; - minMonth?: Date; - maxMonth?: Date; monthSelectId: string; yearSelectId: string; years: number[]; @@ -21,8 +19,6 @@ interface CalendarNavigationProps { const CalendarNavigation = ({ displayedMonth, - minMonth, - maxMonth, monthSelectId, yearSelectId, years, @@ -35,14 +31,6 @@ const CalendarNavigation = ({ const monthOptions = Array.from({ length: 12 }, (_, month) => ({ value: month, label: localize?.month(month as DateFnsMonth) || String(month + 1), - disabled: Boolean( - (minMonth && - displayedYear === minMonth.getFullYear() && - month < minMonth.getMonth()) || - (maxMonth && - displayedYear === maxMonth.getFullYear() && - month > maxMonth.getMonth()), - ), })); return ( @@ -59,7 +47,6 @@ const CalendarNavigation = ({ options={monthOptions} value={displayedMonth.getMonth()} onChange={onMonthChange} - disabled={monthOptions.every(({ disabled }) => disabled)} /> ({ value: year, label: String(year) }))} value={displayedYear} onChange={onYearChange} - disabled={years.length <= 1} /> ); diff --git a/src/components/date/__internal__/calendar/calendar-navigation.test.tsx b/src/components/date/__internal__/calendar/calendar-navigation.test.tsx index 63e86b446c..9f1aa69849 100644 --- a/src/components/date/__internal__/calendar/calendar-navigation.test.tsx +++ b/src/components/date/__internal__/calendar/calendar-navigation.test.tsx @@ -30,7 +30,7 @@ test("year select is enabled when more than one year is selectable", () => { expect(getYearSelect()).toBeEnabled(); }); -test("year select is disabled when only one year is selectable", () => { +test("year select remains enabled when only one year is selectable", () => { render( { />, ); - expect(getYearSelect()).toBeDisabled(); + expect(getYearSelect()).toBeEnabled(); }); -test("year select is disabled when no years are selectable", () => { +test("year select remains enabled when no years are selectable", () => { render( { />, ); - expect(getYearSelect()).toBeDisabled(); + expect(getYearSelect()).toBeEnabled(); }); -test("month select disables months before minMonth in the boundary year", () => { +test("month select options are never disabled", () => { render( expect(monthSelect).toBeEnabled(); expect( within(monthSelect).getByRole("option", { name: "January" }), - ).toBeDisabled(); - expect( - within(monthSelect).getByRole("option", { name: "February" }), - ).toBeDisabled(); - expect( - within(monthSelect).getByRole("option", { name: "March" }), ).toBeEnabled(); expect( within(monthSelect).getByRole("option", { name: "June" }), ).toBeEnabled(); -}); - -test("month select disables months after maxMonth in the boundary year", () => { - render( - , - ); - - const monthSelect = getMonthSelect(); - expect(monthSelect).toBeEnabled(); - expect( - within(monthSelect).getByRole("option", { name: "August" }), - ).toBeEnabled(); - expect( - within(monthSelect).getByRole("option", { name: "September" }), - ).toBeDisabled(); - expect( - within(monthSelect).getByRole("option", { name: "December" }), - ).toBeDisabled(); -}); - -test("minMonth/maxMonth bounds only apply to the year they fall in", () => { - render( - , - ); - - const monthSelect = getMonthSelect(); - expect(monthSelect).toBeEnabled(); - expect( - within(monthSelect).getByRole("option", { name: "January" }), - ).toBeEnabled(); expect( within(monthSelect).getByRole("option", { name: "December" }), ).toBeEnabled(); }); -test("month select is disabled entirely when every month is disabled", () => { - render( - , - ); - - expect(getMonthSelect()).toBeDisabled(); -}); - test("calls onMonthChange and onYearChange when the user picks a value", async () => { const user = userEvent.setup(); const onMonthChange = jest.fn(); diff --git a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts index 39aca51eee..1479c90f88 100644 --- a/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts +++ b/src/components/date/__internal__/calendar/calendar-select/calendar-select.style.ts @@ -49,7 +49,6 @@ export const StyledSelect = styled.select` } &:disabled { - background: var(--input-typical-bg-disabled); cursor: not-allowed; } @@ -77,12 +76,6 @@ export const StyledSelect = styled.select` color: var(--input-dropdown-label-alt); } - option:disabled { - color: var(--input-dropdown-label-disabled); - background: var(--popover-bg-default); - cursor: not-allowed; - } - @supports (appearance: base-select) { &, &::picker(select) { @@ -145,17 +138,17 @@ export const StyledSelect = styled.select` content: "\\f003"; } - option:not(:disabled):hover, - option:not(:disabled):focus { + option:hover, + option:focus { color: var(--input-dropdown-label-hover); } - option:not(:disabled):hover { + option:hover { background: var(--input-dropdown-bg-hover); } - option:not(:disabled):focus, - option:not(:disabled):focus-visible { + option:focus, + option:focus-visible { ${addFocusStyling(true)} color: var(--input-dropdown-label-hover); } diff --git a/src/components/date/__internal__/date-picker/date-picker.component.tsx b/src/components/date/__internal__/date-picker/date-picker.component.tsx index a3c6a789a1..5a0a5e2aa4 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -96,21 +96,15 @@ export const DatePicker = ({ yearSelectId, onEscape: handleEscape, }); - const { - displayedMonth, - minMonth, - maxMonth, - years, - handleMonthChange, - handleYearChange, - } = useDatePickerMonthYearSelection({ - minDate, - maxDate, - dayPickerProps, - focusedMonth, - setFocusedMonth, - markSelectorChanged, - }); + const { displayedMonth, years, handleMonthChange, handleYearChange } = + useDatePickerMonthYearSelection({ + minDate, + maxDate, + dayPickerProps, + focusedMonth, + setFocusedMonth, + markSelectorChanged, + }); useDatePickerMonthSync({ open, selectedDate, @@ -163,8 +157,6 @@ export const DatePicker = ({ )} { }); }); -test("builds a stable one-sided year range from its anchor", () => { +test("adds the offset as a buffer beyond a one-sided bound", () => { expect( buildYearRange({ minMonth: new Date(2025, 0, 1), @@ -67,7 +66,7 @@ test("builds a stable one-sided year range from its anchor", () => { includedYear: 2025, offset: 2, }), - ).toEqual([2025, 2026, 2027]); + ).toEqual([2023, 2024, 2025, 2026, 2027]); }); test("shifts an unbounded range enough to include the displayed year", () => { @@ -81,38 +80,44 @@ test("shifts an unbounded range enough to include the displayed year", () => { expect(years.at(-1)).toBe(2225); }); -test("clamps a month to the available months in a boundary year", () => { - expect( - getMonthWithinDateRangeForYear({ - year: 2025, - month: 0, - minMonth: new Date(2025, 3, 10), - }), - ).toBe(3); +test("adds the offset beyond a wide bound as well", () => { + const years = buildYearRange({ + minMonth: new Date(1900, 0, 1), + maxMonth: new Date(2200, 0, 1), + anchorYear: 2025, + includedYear: 2025, + offset: 10, + }); + + expect(years[0]).toBe(1890); + expect(years.at(-1)).toBe(2210); + expect(years).toHaveLength(321); }); -test("clamps a month to both lower and upper boundary years", () => { +test("adds the offset as a buffer even when a bound is narrower than the offset", () => { expect( - getMonthWithinDateRangeForYear({ - year: 2025, - month: 11, - minMonth: new Date(2025, 3, 10), - maxMonth: new Date(2025, 8, 20), + buildYearRange({ + minMonth: new Date(2023, 0, 1), + maxMonth: new Date(2027, 0, 1), + anchorYear: 2025, + includedYear: 2025, + offset: 10, }), - ).toBe(8); + ).toEqual( + Array.from({ length: 25 }, (_, index) => 2013 + index), // 2013 - 2037 + ); }); -test("calculates a clamped month and year transition", () => { +test("calculates a month and year transition without clamping to any bound", () => { expect( getMonthYearTransition({ displayedMonth: new Date(2025, 0, 15), month: 0, year: 2024, - minMonth: new Date(2024, 3, 10), }), ).toEqual({ - date: new Date(2024, 3, 1), - month: 3, + date: new Date(2024, 0, 1), + month: 0, year: 2024, }); }); diff --git a/src/components/date/__internal__/date-picker/date-picker.utils.ts b/src/components/date/__internal__/date-picker/date-picker.utils.ts index fb888bdb43..87efdf60b7 100644 --- a/src/components/date/__internal__/date-picker/date-picker.utils.ts +++ b/src/components/date/__internal__/date-picker/date-picker.utils.ts @@ -51,8 +51,16 @@ export const buildYearRange = ({ Math.max(boundedAnchor, includedYear - offset), includedYear + offset, ); - const firstYear = minMonth?.getFullYear() ?? rangeAnchor - offset; - const lastYear = maxMonth?.getFullYear() ?? rangeAnchor + offset; + // The offset is always added as a navigable buffer beyond a bound, so + // users can still browse years outside a narrow minDate/maxDate range + // (mirroring the always-enabled month selector). Without a bound on a + // side, that side falls back to a stable window around the anchor. + const firstYear = minMonth + ? minMonth.getFullYear() - offset + : rangeAnchor - offset; + const lastYear = maxMonth + ? maxMonth.getFullYear() + offset + : rangeAnchor + offset; return Array.from( { length: lastYear - firstYear + 1 }, @@ -60,55 +68,21 @@ export const buildYearRange = ({ ); }; -export const getMonthWithinDateRangeForYear = ({ - year, - month, - minMonth, - maxMonth, -}: { - year: number; - month: number; - minMonth?: Date; - maxMonth?: Date; -}) => { - let clampedMonth = month; - - if (minMonth && year === minMonth.getFullYear()) { - clampedMonth = Math.max(clampedMonth, minMonth.getMonth()); - } - - if (maxMonth && year === maxMonth.getFullYear()) { - clampedMonth = Math.min(clampedMonth, maxMonth.getMonth()); - } - - return clampedMonth; -}; - export const getMonthYearTransition = ({ displayedMonth, month, year, - minMonth, - maxMonth, }: { displayedMonth: Date; month: number; year: number; - minMonth?: Date; - maxMonth?: Date; }) => { - const nextMonth = getMonthWithinDateRangeForYear({ - year, - month, - minMonth, - maxMonth, - }); const date = new Date(displayedMonth); date.setDate(1); date.setFullYear(year); - date.setMonth(nextMonth); + date.setMonth(month); - return { date, month: nextMonth, year }; + return { date, month, year }; }; export const getSelectedDateForMonth = ({ diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts index 13c1cf801d..95ad53dab0 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts @@ -40,9 +40,8 @@ test("uses picker bounds to build the available years", () => { }, }); - expect(result.current.minMonth).toEqual(new Date(2023, 3, 10)); - expect(result.current.maxMonth).toEqual(new Date(2026, 8, 20)); - expect(result.current.years).toEqual([2023, 2024, 2025, 2026]); + expect(result.current.years[0]).toBe(2013); + expect(result.current.years.at(-1)).toBe(2036); }); test("parses and normalizes reversed date bounds", () => { @@ -51,23 +50,22 @@ test("parses and normalizes reversed date bounds", () => { maxDate: "2025-09-20", }); - expect(result.current.minMonth).toEqual(new Date(2025, 8, 20)); - expect(result.current.maxMonth).toEqual(new Date(2027, 3, 10)); - expect(result.current.years).toEqual([2025, 2026, 2027]); + expect(result.current.years[0]).toBe(2015); + expect(result.current.years.at(-1)).toBe(2037); }); test.each([ { bound: { minDate: "2200-04-10" }, displayedMonth: new Date(2200, 3, 10), - firstYear: 2200, - lastYear: 2300, + firstYear: 2190, + lastYear: 2210, }, { bound: { maxDate: "1900-09-20" }, displayedMonth: new Date(1900, 8, 20), - firstYear: 1800, - lastYear: 1900, + firstYear: 1890, + lastYear: 1910, }, ])( "keeps the displayed year in a distant one-sided $bound range", @@ -92,7 +90,7 @@ test("changes month and navigates to the first of the new month", () => { expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2025, 1, 1)); }); -test("clamps the month and focused day when changing to a boundary year", () => { +test("navigates to a boundary year without clamping the month outside its selectable range", () => { const { result, setFocusedMonth, markSelectorChanged } = renderMonthYearSelection({ minDate: "2024-04-10", @@ -103,10 +101,10 @@ test("clamps the month and focused day when changing to a boundary year", () => act(() => result.current.handleYearChange(event)); expect(markSelectorChanged).toHaveBeenCalledWith("year"); - expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2024, 3, 1)); + expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2024, 0, 1)); }); -test("does not use picker navigation bounds to clamp the focused day", () => { +test("navigates to a boundary year without clamping when using picker navigation bounds", () => { const { result, setFocusedMonth } = renderMonthYearSelection({ dayPickerProps: { startMonth: new Date(2024, 3, 10) }, focusedMonth: new Date(2025, 0, 15), @@ -115,7 +113,7 @@ test("does not use picker navigation bounds to clamp the focused day", () => { act(() => result.current.handleYearChange(event)); - expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2024, 3, 1)); + expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2024, 0, 1)); }); test("navigates the focused month when there is no selected date", () => { @@ -171,7 +169,7 @@ test("keeps the displayed year available when initial bounds are removed", () => expect(result.current.displayedMonth).toEqual(focusedMonth); expect(result.current.years).toContain(focusedMonth.getFullYear()); expect(result.current.years[0]).toBe(2025); - expect(result.current.years.at(-1)).toBe(2225); + expect(result.current.years.at(-1)).toBe(2045); }); test("recalculates the displayed month and years when bounds change dynamically", () => { @@ -192,7 +190,7 @@ test("recalculates the displayed month and years when bounds change dynamically" rerender({ minDate: "2030-04-10" }); expect(result.current.displayedMonth).toEqual(new Date(2030, 3, 10)); - expect(result.current.years[0]).toBe(2030); + expect(result.current.years[0]).toBe(2020); expect(setFocusedMonth).not.toHaveBeenCalled(); }); @@ -215,11 +213,11 @@ test("keeps the default year range stable after changing year", () => { }); const initialYears = result.current.years; - act(() => result.current.handleYearChange(changeEvent("2125"))); + act(() => result.current.handleYearChange(changeEvent("2030"))); - expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2125, 5, 1)); - expect(result.current.displayedMonth).toEqual(new Date(2125, 5, 1)); + expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2030, 5, 1)); + expect(result.current.displayedMonth).toEqual(new Date(2030, 5, 1)); expect(result.current.years).toEqual(initialYears); - expect(result.current.years[0]).toBe(1925); - expect(result.current.years.at(-1)).toBe(2125); + expect(result.current.years[0]).toBe(2015); + expect(result.current.years.at(-1)).toBe(2035); }); diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts index 64cda4829a..7786691b0e 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts @@ -8,7 +8,7 @@ import { parseSelectInteger, } from "../date-picker/date-picker.utils"; -const DEFAULT_YEAR_RANGE_OFFSET = 100; +const DEFAULT_YEAR_RANGE_OFFSET = 10; interface UseDatePickerMonthYearSelectionProps { minDate?: string; @@ -74,8 +74,6 @@ const useDatePickerMonthYearSelection = ({ displayedMonth, month, year, - minMonth, - maxMonth, }); setFocusedMonth(transition.date); return transition; @@ -103,8 +101,6 @@ const useDatePickerMonthYearSelection = ({ return { displayedMonth, - minMonth, - maxMonth, years, handleMonthChange, handleYearChange, diff --git a/src/components/date/date-typical.test.tsx b/src/components/date/date-typical.test.tsx index feaca08841..513804cd61 100644 --- a/src/components/date/date-typical.test.tsx +++ b/src/components/date/date-typical.test.tsx @@ -421,7 +421,7 @@ test("should cycle focus inside picker for tab and shift-tab in single mode", as expect(todayButton).toHaveFocus(); }); -test("should skip a disabled year selector when cycling focus", async () => { +test("should keep the year selector enabled and in the focus cycle when minDate/maxDate narrow the range to a single year", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render( { name: "Choose the year", }); - expect(yearSelector).toBeDisabled(); + expect(monthSelector).toBeEnabled(); + expect(yearSelector).toBeEnabled(); await user.tab(); expect(closeButton).toHaveFocus(); await user.tab(); expect(monthSelector).toHaveFocus(); await user.tab(); + expect(yearSelector).toHaveFocus(); + await user.tab(); expect(selectedDay).toHaveFocus(); await user.tab({ shift: true }); - expect(monthSelector).toHaveFocus(); + expect(yearSelector).toHaveFocus(); }); test("should clear pending selector navigation when shift-tabbing away", async () => { diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index d8f9a928e0..dded10f300 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -209,7 +209,7 @@ DisabledDatesInCalendar.parameters = { chromatic: { disableSnapshot: false }, }; -export const DisabledYearAndMonthSelectors: Story = () => { +export const DisabledDatesOutsideMinMaxRange: Story = () => { return ( { gap="var(--global-space-layout-xs)" > - The year and month selectors disable automatically when minDate and - maxDate narrow the selectable range: only June 2024 is selectable below, - so the year selector is disabled (only one year available) and every - month except June is disabled. + Days outside the minDate/maxDate range are disabled in the calendar + below: only dates from 10th to 20th June 2024 are selectable. The year + and month selectors themselves remain enabled so users can still + navigate to, and view, months outside the selectable range. ); }; -DisabledYearAndMonthSelectors.storyName = "Disabled Year and Month Selectors"; -DisabledYearAndMonthSelectors.parameters = { +DisabledDatesOutsideMinMaxRange.storyName = + "Disabled Dates Outside Min/Max Range"; +DisabledDatesOutsideMinMaxRange.parameters = { chromatic: { disableSnapshot: false }, }; From c35d4675bbf6ecf9f34e18444b9cb56053200e33 Mon Sep 17 00:00:00 2001 From: iryna Date: Mon, 7 Sep 2026 13:23:06 +0300 Subject: [PATCH 10/12] feat(date): allow naviation on disabled dates --- .../date-picker/date-picker.test.tsx | 26 ++++++++++++++ .../useDatePickerMonthYearSelection.test.ts | 35 +++++++++++++++++++ .../hooks/useDatePickerMonthYearSelection.ts | 15 ++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/components/date/__internal__/date-picker/date-picker.test.tsx b/src/components/date/__internal__/date-picker/date-picker.test.tsx index 10016cc196..20c1309dc1 100644 --- a/src/components/date/__internal__/date-picker/date-picker.test.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.test.tsx @@ -132,3 +132,29 @@ test("disables dates outside the configured range", () => { }), ).toBeEnabled(); }); + +test("allows navigating to, and viewing, a month outside minDate/maxDate via the selectors", async () => { + const user = userEvent.setup(); + render( + {}} + open + disablePortal + selectedDate={new Date(2024, 5, 15)} + minDate="2024-06-10" + maxDate="2024-06-20" + />, + ); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Choose the year" }), + "2025", + ); + + expect(screen.getByRole("combobox", { name: "Choose the year" })).toHaveValue( + "2025", + ); + expect( + screen.getByRole("button", { name: "Monday, June 30th, 2025" }), + ).toBeDisabled(); +}); diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts index 95ad53dab0..20501bd7d1 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts @@ -194,6 +194,41 @@ test("recalculates the displayed month and years when bounds change dynamically" expect(setFocusedMonth).not.toHaveBeenCalled(); }); +test("keeps displaying a month/year the user explicitly navigated to outside minDate/maxDate", () => { + const setFocusedMonth = jest.fn(); + const markSelectorChanged = jest.fn(); + const { result, rerender } = renderHook( + ({ focusedMonth }: { focusedMonth?: Date }) => + useDatePickerMonthYearSelection({ + minDate: "2024-06-10", + maxDate: "2024-06-20", + focusedMonth, + setFocusedMonth, + markSelectorChanged, + }), + { initialProps: { focusedMonth: new Date(2024, 5, 15) } }, + ); + + act(() => result.current.handleYearChange(changeEvent("2030"))); + + expect(setFocusedMonth).toHaveBeenCalledWith(new Date(2030, 5, 1)); + + // Simulate the resulting state update flowing back in as focusedMonth. + rerender({ focusedMonth: new Date(2030, 5, 1) }); + + expect(result.current.displayedMonth).toEqual(new Date(2030, 5, 1)); +}); + +test("still clamps an out-of-range focusedMonth that did not come from user navigation", () => { + const { result } = renderMonthYearSelection({ + minDate: "2024-06-10", + maxDate: "2024-06-20", + focusedMonth: new Date(2030, 5, 15), + }); + + expect(result.current.displayedMonth).toEqual(new Date(2024, 5, 20)); +}); + test("keeps the default year range stable after changing year", () => { const setFocusedMonth = jest.fn(); const markSelectorChanged = jest.fn(); diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts index 7786691b0e..473c61198a 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts @@ -40,10 +40,21 @@ const useDatePickerMonthYearSelection = ({ ); const { minMonth, maxMonth } = navigationBounds; const currentMonth = focusedMonth ?? new Date(); + // Only clamp into range when minDate/maxDate just changed (including on + // mount), so month/year selector navigation to an out-of-range month is + // never immediately overridden. + const previousBounds = useRef<{ min?: number; max?: number }>(); + const boundsChanged = + previousBounds.current?.min !== minMonth?.getTime() || + previousBounds.current?.max !== maxMonth?.getTime(); + previousBounds.current = { + min: minMonth?.getTime(), + max: maxMonth?.getTime(), + }; const displayedMonth = - minMonth && currentMonth < minMonth + boundsChanged && minMonth && currentMonth < minMonth ? minMonth - : maxMonth && currentMonth > maxMonth + : boundsChanged && maxMonth && currentMonth > maxMonth ? maxMonth : currentMonth; const displayedYear = displayedMonth.getFullYear(); From e97f1b393e19e957af432e384f8ef3aa9197ade8 Mon Sep 17 00:00:00 2001 From: iryna Date: Mon, 7 Sep 2026 13:59:28 +0300 Subject: [PATCH 11/12] feat(date): add yearRangeOffset to public API --- skills/carbon-react/components/date-input.md | 1 + .../date-picker/date-picker.component.tsx | 2 ++ .../date-picker/date-picker.test.tsx | 21 +++++++++++++++++++ .../date-picker/date-picker.types.ts | 5 +++++ .../useDatePickerMonthYearSelection.test.ts | 19 +++++++++++++++++ .../hooks/useDatePickerMonthYearSelection.ts | 9 +++++--- src/components/date/date.component.tsx | 8 +++++++ src/components/date/date.test.tsx | 21 +++++++++++++++++++ 8 files changed, 83 insertions(+), 3 deletions(-) diff --git a/skills/carbon-react/components/date-input.md b/skills/carbon-react/components/date-input.md index bfbf7c9711..1f1ee536b0 100644 --- a/skills/carbon-react/components/date-input.md +++ b/skills/carbon-react/components/date-input.md @@ -285,6 +285,7 @@ description: Carbon DateInput component props and usage examples. | vocab | string \| undefined | No | | | | | | | warning | string \| boolean \| undefined | No | | | | Indicate that warning has occurred. | | | width | string \| number \| undefined | No | | | | | | +| yearRangeOffset | number \| undefined | No | | | | Number of years shown either side of the bounds (`minDate`/`maxDate`, or `pickerProps.startMonth`/`endMonth`) or the anchor year in the year selector dropdown. Defaults to 10. | | | data-element | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | | data-role | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | | aria-activedescendant | string \| undefined | No | | | | Identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application. | | diff --git a/src/components/date/__internal__/date-picker/date-picker.component.tsx b/src/components/date/__internal__/date-picker/date-picker.component.tsx index 5a0a5e2aa4..8be37fa0ae 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -38,6 +38,7 @@ export const DatePicker = ({ onRequestPickerClose, pickerTabGuardId, pickerId, + yearRangeOffset, }: DatePickerProps) => { if (!deprecateDisablePortalWarnTriggered && disablePortal) { deprecateDisablePortalWarnTriggered = true; @@ -104,6 +105,7 @@ export const DatePicker = ({ focusedMonth, setFocusedMonth, markSelectorChanged, + yearRangeOffset, }); useDatePickerMonthSync({ open, diff --git a/src/components/date/__internal__/date-picker/date-picker.test.tsx b/src/components/date/__internal__/date-picker/date-picker.test.tsx index 20c1309dc1..4838eba635 100644 --- a/src/components/date/__internal__/date-picker/date-picker.test.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.test.tsx @@ -158,3 +158,24 @@ test("allows navigating to, and viewing, a month outside minDate/maxDate via the screen.getByRole("button", { name: "Monday, June 30th, 2025" }), ).toBeDisabled(); }); + +test("supports a custom yearRangeOffset to narrow the year selector options", () => { + render( + {}} + open + disablePortal + selectedDate={new Date(2025, 5, 15)} + yearRangeOffset={1} + />, + ); + + const yearSelect = screen.getByRole("combobox", { + name: "Choose the year", + }) as HTMLSelectElement; + const optionValues = Array.from(yearSelect.options).map( + (option) => option.value, + ); + + expect(optionValues).toEqual(["2024", "2025", "2026"]); +}); diff --git a/src/components/date/__internal__/date-picker/date-picker.types.ts b/src/components/date/__internal__/date-picker/date-picker.types.ts index 9a946c9280..bfc36fd084 100644 --- a/src/components/date/__internal__/date-picker/date-picker.types.ts +++ b/src/components/date/__internal__/date-picker/date-picker.types.ts @@ -35,4 +35,9 @@ export interface DatePickerProps { pickerId?: string; /** Size of the associated date input. */ size?: DatePickerSize; + /** + * Number of years shown either side of the bounds/anchor year in the year + * selector. Defaults to 10. + */ + yearRangeOffset?: number; } diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts index 20501bd7d1..3bd0722559 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.test.ts @@ -229,6 +229,25 @@ test("still clamps an out-of-range focusedMonth that did not come from user navi expect(result.current.displayedMonth).toEqual(new Date(2024, 5, 20)); }); +test("uses the default year range offset of 10 when none is provided", () => { + const { result } = renderMonthYearSelection({ + focusedMonth: new Date(2025, 5, 15), + }); + + expect(result.current.years[0]).toBe(2015); + expect(result.current.years.at(-1)).toBe(2035); +}); + +test("uses a custom yearRangeOffset to build the available years", () => { + const { result } = renderMonthYearSelection({ + focusedMonth: new Date(2025, 5, 15), + yearRangeOffset: 2, + }); + + expect(result.current.years[0]).toBe(2023); + expect(result.current.years.at(-1)).toBe(2027); +}); + test("keeps the default year range stable after changing year", () => { const setFocusedMonth = jest.fn(); const markSelectorChanged = jest.fn(); diff --git a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts index 473c61198a..7029a488e0 100644 --- a/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts +++ b/src/components/date/__internal__/hooks/useDatePickerMonthYearSelection.ts @@ -8,7 +8,7 @@ import { parseSelectInteger, } from "../date-picker/date-picker.utils"; -const DEFAULT_YEAR_RANGE_OFFSET = 10; +export const DEFAULT_YEAR_RANGE_OFFSET = 10; interface UseDatePickerMonthYearSelectionProps { minDate?: string; @@ -17,6 +17,8 @@ interface UseDatePickerMonthYearSelectionProps { focusedMonth?: Date; setFocusedMonth: React.Dispatch>; markSelectorChanged: (selector: "month" | "year") => void; + /** Number of years shown either side of the bounds/anchor year in the year selector. */ + yearRangeOffset?: number; } /** Manages bounded month/year navigation and selector changes. */ @@ -27,6 +29,7 @@ const useDatePickerMonthYearSelection = ({ focusedMonth, setFocusedMonth, markSelectorChanged, + yearRangeOffset = DEFAULT_YEAR_RANGE_OFFSET, }: UseDatePickerMonthYearSelectionProps) => { const navigationBounds = useMemo( () => @@ -69,9 +72,9 @@ const useDatePickerMonthYearSelection = ({ maxMonth, anchorYear: initialDisplayedYear.current, includedYear: displayedYear, - offset: DEFAULT_YEAR_RANGE_OFFSET, + offset: yearRangeOffset, }), - [displayedYear, minMonth, maxMonth], + [displayedYear, minMonth, maxMonth, yearRangeOffset], ); const navigateToMonthAndYear = ({ diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index a0978d8071..45818ab1f5 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -106,6 +106,12 @@ export interface DateInputProps datePickerAriaLabelledBy?: string; /** Date input presentation. Typical is the default; legacy retains its icon trigger. */ variant?: "legacy" | "typical"; + /** + * Number of years shown either side of the bounds (`minDate`/`maxDate`, or + * `pickerProps.startMonth`/`endMonth`) or the anchor year in the year + * selector dropdown. Defaults to 10. + */ + yearRangeOffset?: number; } export const DateInput = React.forwardRef( @@ -163,6 +169,7 @@ export const DateInput = React.forwardRef( variant = "typical", warning, name, + yearRangeOffset, ...rest }: DateInputProps, ref, @@ -460,6 +467,7 @@ export const DateInput = React.forwardRef( pickerTabGuardId={pickerTabGuardId} pickerId={pickerId} size={actualSize} + yearRangeOffset={yearRangeOffset} /> ); diff --git a/src/components/date/date.test.tsx b/src/components/date/date.test.tsx index 36e396f4f5..e10bc82135 100644 --- a/src/components/date/date.test.tsx +++ b/src/components/date/date.test.tsx @@ -616,6 +616,27 @@ test("should render month and year navigation selects", async () => { ).toBeVisible(); }); +test("should narrow the year navigation select options via yearRangeOffset", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + {}} + value="15/06/2025" + yearRangeOffset={1} + />, + ); + await user.click(screen.getByTestId("input-icon-toggle")); + + const yearSelect = screen.getByRole("combobox", { + name: "Choose the year", + }) as HTMLSelectElement; + const optionValues = Array.from(yearSelect.options).map( + (option) => option.value, + ); + + expect(optionValues).toEqual(["2024", "2025", "2026"]); +}); + test("should update a legacy input only after selecting a date, not when navigating by month or year", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const onChange = jest.fn(); From 83408ea75027bfc49c0de19324ccfd785e916b8e Mon Sep 17 00:00:00 2001 From: iryna Date: Mon, 7 Sep 2026 15:23:16 +0300 Subject: [PATCH 12/12] feat(date): fix anchor ref in documents --- src/components/date/date.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/date/date.mdx b/src/components/date/date.mdx index 86822b083f..944fab6b95 100644 --- a/src/components/date/date.mdx +++ b/src/components/date/date.mdx @@ -35,7 +35,7 @@ import * as DateStories from "./date.stories"; import DateInput from "carbon-react/lib/components/date"; ``` -## Compatibility +## Typical variant compatibility The `typical` variant is the default. Deprecated props remain part of the shared API to avoid a breaking type change, but the following legacy presentation props are no longer supported by either variant.