From 8e297e6311b908b33a952108386be2976d4bd11e Mon Sep 17 00:00:00 2001 From: nuria1110 Date: Tue, 3 Dec 2024 14:31:43 +0000 Subject: [PATCH 01/21] feat(decimal): add a deprecation warning for onKeyPress prop Deprecates onKeyPress prop in Decimal, onKeyDown should be used instead. --- src/components/decimal/decimal.component.tsx | 10 ++++++++- src/components/decimal/decimal.test.tsx | 22 +++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/decimal/decimal.component.tsx b/src/components/decimal/decimal.component.tsx index c7bb2dc305..7432f57d47 100644 --- a/src/components/decimal/decimal.component.tsx +++ b/src/components/decimal/decimal.component.tsx @@ -39,7 +39,7 @@ export interface DecimalProps onChange?: (ev: CustomEvent) => void; /** Handler for blur event */ onBlur?: (ev: CustomEvent) => void; - /** Handler for key press event */ + /** [Deprecated] Handler for key press event */ onKeyPress?: (ev: React.KeyboardEvent) => void; /** The input name */ name?: string; @@ -70,6 +70,7 @@ export interface DecimalProps } let deprecateUncontrolledWarnTriggered = false; +let deprecateOnKeyPressWarnTriggered = false; export const Decimal = React.forwardRef( ( @@ -325,6 +326,13 @@ export const Decimal = React.forwardRef( ); } + if (!deprecateOnKeyPressWarnTriggered && onKeyPress) { + deprecateOnKeyPressWarnTriggered = true; + Logger.deprecate( + "`onKeyPress` prop in `Decimal` is deprecated and will soon be removed, please use `onKeyDown` instead.", + ); + } + return ( <> { - it("displays a deprecation warning", () => { + it("displays a deprecation warning for uncontrolled", () => { const loggerSpy = jest.spyOn(Logger, "deprecate"); render(); @@ -44,6 +44,18 @@ describe("when the component is uncontrolled", () => { loggerSpy.mockRestore(); }); + it("displays a deprecation warning for `onKeyPress`", () => { + const loggerSpy = jest.spyOn(Logger, "deprecate"); + render( {}} />); + + expect(loggerSpy).toHaveBeenCalledWith( + "`onKeyPress` prop in `Decimal` is deprecated and will soon be removed, please use `onKeyDown` instead.", + ); + expect(loggerSpy).toHaveBeenCalledTimes(1); + + loggerSpy.mockRestore(); + }); + it("has a default value of 0.00 when no defaultValue prop is provided", () => { render(); expect(screen.getByRole("textbox")).toHaveValue("0.00"); @@ -672,16 +684,16 @@ describe("when the component is uncontrolled", () => { }, ); - it("calls the onKeyPress callback when a key is pressed", async () => { + it("calls the onKeyDown callback when a key is pressed", async () => { const user = userEvent.setup(); - const onKeyPress = jest.fn(); - render(); + const onKeyDown = jest.fn(); + render(); screen.getByRole("textbox").focus(); await user.keyboard("{ArrowRight}"); await user.keyboard("1"); - expect(onKeyPress).toHaveBeenCalledWith( + expect(onKeyDown).toHaveBeenCalledWith( expect.objectContaining({ key: "1" }), ); }); From f8d8a4543adacbe330b40a065b62882e8d7a87bf Mon Sep 17 00:00:00 2001 From: nuria1110 Date: Tue, 3 Dec 2024 14:34:11 +0000 Subject: [PATCH 02/21] refactor(grouped-character): replace onKeyPress with onKeyDown --- .../grouped-character.component.tsx | 14 ++++++++++--- .../grouped-character.test.tsx | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/grouped-character/grouped-character.component.tsx b/src/components/grouped-character/grouped-character.component.tsx index 5a68a3e45f..e311193caa 100644 --- a/src/components/grouped-character/grouped-character.component.tsx +++ b/src/components/grouped-character/grouped-character.component.tsx @@ -57,6 +57,7 @@ export const GroupedCharacter = React.forwardRef( groups, onBlur, onChange, + onKeyDown, separator: rawSeparator, value: externalValue, ...rest @@ -139,15 +140,22 @@ export const GroupedCharacter = React.forwardRef( } }; - const handleKeyPress = (ev: React.KeyboardEvent) => { + const handleKeyDown = (ev: React.KeyboardEvent) => { const { selectionStart, selectionEnd } = ev.target as HTMLInputElement; /* istanbul ignore next */ const hasSelection = (selectionEnd ?? 0) - (selectionStart ?? 0) > 0; - if (maxRawLength === value.length && !hasSelection) { + // check if the key pressed is a character key + const isCharacterKey = ev.key.length === 1; + + if (isCharacterKey && maxRawLength === value.length && !hasSelection) { ev.preventDefault(); } + + if (onKeyDown) { + onKeyDown(ev); + } }; return ( @@ -157,7 +165,7 @@ export const GroupedCharacter = React.forwardRef( formattedValue={formatValue(value)} onChange={handleChange} onBlur={handleBlur} - onKeyPress={handleKeyPress} + onKeyDown={handleKeyDown} ref={ref} /> ); diff --git a/src/components/grouped-character/grouped-character.test.tsx b/src/components/grouped-character/grouped-character.test.tsx index 7f302bdd3e..0a255390e8 100644 --- a/src/components/grouped-character/grouped-character.test.tsx +++ b/src/components/grouped-character/grouped-character.test.tsx @@ -238,6 +238,27 @@ test("does nothing if onBlur is not provided", async () => { expect(onBlur.mock.calls[0]).toBe(undefined); }); +test("calls provided onKeyDown handler", async () => { + const onKeyDown = jest.fn(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + + render( + , + ); + + const input = screen.getByRole("textbox"); + await user.click(input); + await user.clear(input); + await user.type(screen.getByRole("textbox"), "123456"); + + expect(onKeyDown).toHaveBeenCalledTimes(6); +}); + test("does not allow values of length greater than that allowed by the group config", () => { render( , From 7809b0a39420e9539910cd6a90428f75c9ed516f Mon Sep 17 00:00:00 2001 From: nuria1110 Date: Wed, 4 Dec 2024 14:20:32 +0000 Subject: [PATCH 03/21] feat(step-sequence): reinstate component Reinstates StepSequence component --- playwright/components/step-sequence/index.ts | 16 ++ .../components/step-sequence/locators.ts | 5 + .../step-sequence/components.test-pw.tsx | 76 +++++ src/components/step-sequence/index.ts | 4 + .../step-sequence/step-sequence-item/index.ts | 2 + .../step-sequence-item.component.tsx | 87 ++++++ .../step-sequence-item.stories.tsx | 23 ++ .../step-sequence-item.style.ts | 111 ++++++++ .../step-sequence-item.test.tsx | 80 ++++++ .../step-sequence-test.stories.tsx | 123 ++++++++ .../step-sequence/step-sequence.component.tsx | 35 +++ .../step-sequence/step-sequence.mdx | 67 +++++ .../step-sequence/step-sequence.pw.tsx | 269 ++++++++++++++++++ .../step-sequence/step-sequence.stories.tsx | 242 ++++++++++++++++ .../step-sequence/step-sequence.style.ts | 27 ++ .../step-sequence/step-sequence.test.tsx | 41 +++ 16 files changed, 1208 insertions(+) create mode 100644 playwright/components/step-sequence/index.ts create mode 100644 playwright/components/step-sequence/locators.ts create mode 100644 src/components/step-sequence/components.test-pw.tsx create mode 100644 src/components/step-sequence/index.ts create mode 100644 src/components/step-sequence/step-sequence-item/index.ts create mode 100644 src/components/step-sequence/step-sequence-item/step-sequence-item.component.tsx create mode 100644 src/components/step-sequence/step-sequence-item/step-sequence-item.stories.tsx create mode 100644 src/components/step-sequence/step-sequence-item/step-sequence-item.style.ts create mode 100644 src/components/step-sequence/step-sequence-item/step-sequence-item.test.tsx create mode 100644 src/components/step-sequence/step-sequence-test.stories.tsx create mode 100644 src/components/step-sequence/step-sequence.component.tsx create mode 100644 src/components/step-sequence/step-sequence.mdx create mode 100644 src/components/step-sequence/step-sequence.pw.tsx create mode 100644 src/components/step-sequence/step-sequence.stories.tsx create mode 100644 src/components/step-sequence/step-sequence.style.ts create mode 100644 src/components/step-sequence/step-sequence.test.tsx diff --git a/playwright/components/step-sequence/index.ts b/playwright/components/step-sequence/index.ts new file mode 100644 index 0000000000..5765a02b0b --- /dev/null +++ b/playwright/components/step-sequence/index.ts @@ -0,0 +1,16 @@ +import { Page } from "@playwright/test"; +import { + STEP_SEQUENCE_ITEM_INDICATOR, + STEP_SEQUENCE_DATA_COMPONENT, + STEP_SEQUENCE_DATA_COMPONENT_ITEM, +} from "./locators"; + +// component preview locators +export const stepSequenceItemIndicator = (page: Page) => + page.locator(STEP_SEQUENCE_ITEM_INDICATOR).first(); + +export const stepSequenceDataComponentItem = (page: Page) => + page.locator(STEP_SEQUENCE_DATA_COMPONENT_ITEM); + +export const stepSequenceDataComponent = (page: Page) => + page.locator(STEP_SEQUENCE_DATA_COMPONENT); diff --git a/playwright/components/step-sequence/locators.ts b/playwright/components/step-sequence/locators.ts new file mode 100644 index 0000000000..740b9c85ba --- /dev/null +++ b/playwright/components/step-sequence/locators.ts @@ -0,0 +1,5 @@ +// component preview locators +export const STEP_SEQUENCE_DATA_COMPONENT_ITEM = + '[data-component="step-sequence-item"]'; +export const STEP_SEQUENCE_ITEM_INDICATOR = `${STEP_SEQUENCE_DATA_COMPONENT_ITEM} > span > span`; +export const STEP_SEQUENCE_DATA_COMPONENT = '[data-component="step-sequence"]'; diff --git a/src/components/step-sequence/components.test-pw.tsx b/src/components/step-sequence/components.test-pw.tsx new file mode 100644 index 0000000000..8348612246 --- /dev/null +++ b/src/components/step-sequence/components.test-pw.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import StepSequence from "./step-sequence.component"; +import StepSequenceItem, { + StepSequenceItemProps, +} from "./step-sequence-item/step-sequence-item.component"; + +export const StepSequenceComponent = ({ ...props }) => { + return ( + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + + ); +}; + +export const StepSequenceItemCustom = ( + props: Partial, +) => { + return ( + + + Name + + + ); +}; diff --git a/src/components/step-sequence/index.ts b/src/components/step-sequence/index.ts new file mode 100644 index 0000000000..9a1cdf10d1 --- /dev/null +++ b/src/components/step-sequence/index.ts @@ -0,0 +1,4 @@ +export { default as StepSequence } from "./step-sequence.component"; +export type { StepSequenceProps } from "./step-sequence.component"; +export { default as StepSequenceItem } from "./step-sequence-item"; +export type { StepSequenceItemProps } from "./step-sequence-item"; diff --git a/src/components/step-sequence/step-sequence-item/index.ts b/src/components/step-sequence/step-sequence-item/index.ts new file mode 100644 index 0000000000..613e47eb62 --- /dev/null +++ b/src/components/step-sequence/step-sequence-item/index.ts @@ -0,0 +1,2 @@ +export { default } from "./step-sequence-item.component"; +export type { StepSequenceItemProps } from "./step-sequence-item.component"; diff --git a/src/components/step-sequence/step-sequence-item/step-sequence-item.component.tsx b/src/components/step-sequence/step-sequence-item/step-sequence-item.component.tsx new file mode 100644 index 0000000000..78a42166b7 --- /dev/null +++ b/src/components/step-sequence/step-sequence-item/step-sequence-item.component.tsx @@ -0,0 +1,87 @@ +import React, { useContext } from "react"; +import { + StyledStepSequenceItem, + StyledStepSequenceItemContent, + StyledStepSequenceItemIndicator, + StyledStepSequenceItemHiddenLabel, +} from "./step-sequence-item.style"; +import Icon from "../../icon"; +import { StepSequenceContext } from "../step-sequence.component"; + +export interface StepSequenceItemProps { + /** Aria label */ + ariaLabel?: string; + /** Hidden label to be displayed if item is complete */ + hiddenCompleteLabel?: string; + /** Hidden label to be displayed if item is current */ + hiddenCurrentLabel?: string; + /** Value to be displayed before text for incomplete steps */ + indicator: string; + /** Flag to hide the indicator for incomplete steps */ + hideIndicator?: boolean; + /** Status for the step */ + status?: "complete" | "current" | "incomplete"; + /** Content to be displayed */ + children: React.ReactNode; +} + +export const StepSequenceItem = ({ + hideIndicator = false, + indicator, + status = "incomplete", + hiddenCompleteLabel, + hiddenCurrentLabel, + children, + ariaLabel, + ...rest +}: StepSequenceItemProps) => { + const { orientation } = useContext(StepSequenceContext); + + const indicatorText = () => { + return !hideIndicator ? ( + + {indicator} + + ) : null; + }; + + const icon = () => + status === "complete" ? : indicatorText(); + + const hiddenLabel = () => { + if (hiddenCompleteLabel && status === "complete") { + return ( + + {hiddenCompleteLabel} + + ); + } + if (hiddenCurrentLabel && status === "current") { + return ( + + {hiddenCurrentLabel} + + ); + } + return null; + }; + + return ( + + {hiddenLabel()} + + {icon()} + {children} + + + ); +}; + +export default StepSequenceItem; diff --git a/src/components/step-sequence/step-sequence-item/step-sequence-item.stories.tsx b/src/components/step-sequence/step-sequence-item/step-sequence-item.stories.tsx new file mode 100644 index 0000000000..9e32cc99b8 --- /dev/null +++ b/src/components/step-sequence/step-sequence-item/step-sequence-item.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from "@storybook/react"; +import StepSequenceItem from "./step-sequence-item.component"; + +/** + * This file is used primarily as a means to generate the props table. + * It contains the tag: ["hideInSidebar"] so that it is not included in the sidebar. + */ + +const meta: Meta = { + title: "Step Sequence Item", + component: StepSequenceItem, + tags: ["hideInSidebar"], + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/components/step-sequence/step-sequence-item/step-sequence-item.style.ts b/src/components/step-sequence/step-sequence-item/step-sequence-item.style.ts new file mode 100644 index 0000000000..fe51475c5d --- /dev/null +++ b/src/components/step-sequence/step-sequence-item/step-sequence-item.style.ts @@ -0,0 +1,111 @@ +import styled, { css } from "styled-components"; +import { StepSequenceProps } from "../step-sequence.component"; +import { StepSequenceItemProps } from "./step-sequence-item.component"; +import StyledIcon from "../../icon/icon.style"; + +export const StyledStepSequenceItem = styled.li< + Pick & Pick +>` + display: flex; + align-items: center; + flex-grow: 1; + text-align: right; + list-style-type: none; + color: var(--colorsUtilityYin055); + + ${({ orientation, status }) => { + const side: string = orientation === "vertical" ? "left" : "top"; + + return css` + &::before { + content: ""; + flex-grow: 1; + display: block; + margin: 0 16px; + border-${side}: var(--sizing025) dashed var(--colorsUtilityYin055); + } + + & span { + display: flex; + align-items: center; + justify-content: center; + } + + ${StyledIcon} { + margin-right: 8px; + color: var(--colorsBaseTheme, var(--colorsSemanticPositive500)); + } + + &:first-child { + flex-grow: 0; + + &::before { + display: none; + } + } + + ${ + status === "current" && + css` + color: var(--colorsUtilityYin090); + + &::before { + border-${side}-color: var(--colorsUtilityYin090); + border-${side}-style: solid; + } + ` + } + + ${ + status === "complete" && + css` + color: var(--colorsBaseTheme, var(--colorsSemanticPositive500)); + + &::before { + border-${side}-color: var( + --colorsBaseTheme, + var(--colorsSemanticPositive500) + ); + border-${side}-style: solid; + } + ` + } + + ${ + orientation === "vertical" && + css` + flex-direction: column; + align-items: flex-start; + + &::before { + flex-grow: 0; + border-left-width: var(--sizing025); + height: 100%; + min-height: var(--sizing300); + margin: 12px 8px; + } + ` + } + `; + }} +`; + +export const StyledStepSequenceItemContent = styled.span` + display: flex; +`; + +export const StyledStepSequenceItemHiddenLabel = styled.span` + position: absolute !important; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); +`; + +export const StyledStepSequenceItemIndicator = styled.span` + display: block; + min-width: 16px; + height: 16px; + margin-right: 8px; + text-align: center; +`; diff --git a/src/components/step-sequence/step-sequence-item/step-sequence-item.test.tsx b/src/components/step-sequence/step-sequence-item/step-sequence-item.test.tsx new file mode 100644 index 0000000000..1f74ce3ba7 --- /dev/null +++ b/src/components/step-sequence/step-sequence-item/step-sequence-item.test.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import StepSequenceItem from "."; + +test("renders with provided children and indicator", () => { + render(Step); + + const step = screen.getByRole("listitem"); + + expect(step).toBeVisible(); + expect(step).toHaveTextContent("1Step"); +}); + +test("does not render indicator when `hideIndicator` is true", () => { + render( + + Step + , + ); + + const step = screen.getByRole("listitem"); + + expect(step).not.toHaveTextContent("1"); +}); + +test("renders with provided accessible label", () => { + render( + + Step + , + ); + + const step = screen.getByRole("listitem"); + + expect(step).toHaveAccessibleName("Aria Label"); +}); + +test("renders with hidden label when status is 'complete'", () => { + render( + + Step + , + ); + + const step = screen.getByRole("listitem"); + + expect(step).toHaveTextContent("Completed"); +}); + +test("renders with hidden label when status is 'current'", () => { + render( + + Step + , + ); + + const step = screen.getByRole("listitem"); + + expect(step).toHaveTextContent("Current"); +}); + +test("renders with a tick Icon when status is 'complete'", () => { + render( + + Step + , + ); + + const step = screen.getByRole("listitem"); + + expect(within(step).getByTestId("icon")).toHaveAttribute("type", "tick"); +}); diff --git a/src/components/step-sequence/step-sequence-test.stories.tsx b/src/components/step-sequence/step-sequence-test.stories.tsx new file mode 100644 index 0000000000..3b53b85be7 --- /dev/null +++ b/src/components/step-sequence/step-sequence-test.stories.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import StepSequence, { StepSequenceProps } from "./step-sequence.component"; +import StepSequenceItem, { + StepSequenceItemProps, +} from "./step-sequence-item/step-sequence-item.component"; + +export default { + title: "Step Sequence/Test", + parameters: { + info: { disable: true }, + chromatic: { + disableSnapshot: true, + }, + }, + argTypes: { + orientation: { + options: ["horizontal", "vertical"], + control: { + type: "select", + }, + }, + status: { + options: ["complete", "current", "incomplete"], + control: { + type: "select", + }, + }, + }, +}; + +export const StepSequenceStory = (args: Partial) => ( + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + +); + +StepSequenceStory.storyName = "step sequence"; +StepSequenceStory.args = { + orientation: "horizontal", +}; + +interface StepSequenceItemStoryProps extends StepSequenceItemProps { + hideIndicator?: boolean; +} + +export const StepSequenceItemStory = ({ + indicator, + hideIndicator, + hiddenCompleteLabel, + hiddenCurrentLabel, + ariaLabel, + children, + ...args +}: StepSequenceItemStoryProps) => ( + + + {children} + + +); + +StepSequenceItemStory.storyName = "step sequence item"; + +StepSequenceItemStory.args = { + indicator: "1", + hideIndicator: false, + status: "incomplete", + hiddenCompleteLabel: "", + hiddenCurrentLabel: "", + ariaLabel: "Step 1 of 5", + children: "Step Label", +}; diff --git a/src/components/step-sequence/step-sequence.component.tsx b/src/components/step-sequence/step-sequence.component.tsx new file mode 100644 index 0000000000..72d565851d --- /dev/null +++ b/src/components/step-sequence/step-sequence.component.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { SpaceProps } from "styled-system"; +import StyledStepSequence from "./step-sequence.style"; + +export const StepSequenceContext = React.createContext<{ + orientation: "horizontal" | "vertical"; +}>({ orientation: "horizontal" }); + +export interface StepSequenceProps extends SpaceProps { + /** Step sequence items to be rendered */ + children: React.ReactNode; + /** The direction that step sequence items should be rendered */ + orientation?: "horizontal" | "vertical"; +} + +export const StepSequence = ({ + children, + orientation = "horizontal", + ...props +}: StepSequenceProps) => { + return ( + + + {children} + + + ); +}; + +export default StepSequence; diff --git a/src/components/step-sequence/step-sequence.mdx b/src/components/step-sequence/step-sequence.mdx new file mode 100644 index 0000000000..dc24ccf5b8 --- /dev/null +++ b/src/components/step-sequence/step-sequence.mdx @@ -0,0 +1,67 @@ +import { Meta, ArgTypes, Canvas } from "@storybook/blocks"; +import * as StepSequenceItemStories from "./step-sequence-item/step-sequence-item.stories.tsx"; +import * as StepSequenceStories from "./step-sequence.stories.tsx"; + + + +# StepSequence + + + Product Design System component + + +Indicate the progress of a step flow. + +Progress indicators always directly mirror the number of pages, not general categories. + +Try to keep label text for each step as short as possible. + +For users on small screens or instances where horizontal space is limited, use the vertical version of this component. + +## Contents + +- [Quick Start](#quick-start) +- [Examples](#examples) +- [Props](#props) + +## Quick Start + +```javascript +import { + StepSequence, + StepSequenceItem, +} from "carbon-react/lib/components/step-sequence"; +``` + +## Examples + +### Default + + + +### Vertical + + + +### With hidden indicators + + + +### Responsive Example + + + +## Props + +### StepSequence + + + +### StepSequenceItem + + \ No newline at end of file diff --git a/src/components/step-sequence/step-sequence.pw.tsx b/src/components/step-sequence/step-sequence.pw.tsx new file mode 100644 index 0000000000..6afa46352c --- /dev/null +++ b/src/components/step-sequence/step-sequence.pw.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import { test, expect } from "@playwright/experimental-ct-react17"; +import { + StepSequenceComponent, + StepSequenceItemCustom, +} from "./components.test-pw"; + +import { StepSequenceItemProps } from "."; +import { checkAccessibility } from "../../../playwright/support/helper"; +import { + stepSequenceDataComponent, + stepSequenceDataComponentItem, +} from "../../../playwright/components/step-sequence"; + +import { ICON } from "../../../playwright/components/locators"; + +import { CHARACTERS } from "../../../playwright/support/constants"; + +const testData = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS]; + +test.describe("Testing StepSequence component properties", () => { + ["horizontal", "vertical"].forEach((orientation) => { + test(`should check orientation is set to ${orientation}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(stepSequenceDataComponent(page)).toHaveAttribute( + "orientation", + orientation, + ); + + if (orientation === "vertical") { + await expect(stepSequenceDataComponent(page)).toHaveCSS( + "flex-direction", + "column", + ); + } + }); + }); + + test("should check StepSequenceItem with children", async ({ + mount, + page, + }) => { + await mount(); + + await expect( + stepSequenceDataComponentItem(page).locator("span").nth(1), + ).toHaveText("Name"); + }); + + ["-100", "0", "999", testData[0], testData[1]].forEach((indicator) => { + test(`should check indicator is set to ${indicator}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + const expectedLabelChild = stepSequenceDataComponentItem(page) + .locator("span > span") + .nth(0); + await expect(expectedLabelChild).toHaveText(indicator); + }); + }); + + testData.forEach((ariaLabel) => { + test(`should check ariaLabel is set to ${ariaLabel}`, async ({ + mount, + page, + }) => { + await mount(); + await expect(stepSequenceDataComponentItem(page)).toHaveAttribute( + "aria-label", + ariaLabel, + ); + }); + }); + + ( + [ + ["complete", "rgb(0, 138, 33)"], + ["current", "rgba(0, 0, 0, 0.9)"], + ["incomplete", "rgba(0, 0, 0, 0.55)"], + ] as [StepSequenceItemProps["status"], string][] + ).forEach(([status, color]) => { + test(`should check status is set to ${status}`, async ({ mount, page }) => { + await mount(); + await expect(stepSequenceDataComponentItem(page)).toHaveCSS( + "color", + color, + ); + + const expectedLabelChild = stepSequenceDataComponentItem(page) + .locator("span > span") + .nth(0); + await expect(expectedLabelChild).toHaveCSS("color", color); + }); + }); + + testData.forEach((hiddenCompleteLabel) => { + test(`should check hiddenCompleteLabel is set to ${hiddenCompleteLabel}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + + const expectedLabelChild = stepSequenceDataComponentItem(page) + .locator("span") + .nth(0); + await expect(expectedLabelChild).toHaveText(hiddenCompleteLabel); + }); + }); + + testData.forEach((hiddenCurrentLabel) => { + test(`should check hiddenCurrentLabel is set to ${hiddenCurrentLabel}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + const expectedLabelChild = stepSequenceDataComponentItem(page) + .locator("span") + .nth(0); + await expect(expectedLabelChild).toHaveText(hiddenCurrentLabel); + }); + }); + + test("should check hideIndicator prop when status is set to complete", async ({ + mount, + page, + }) => { + await mount(); + const expectedLabelChild = + stepSequenceDataComponentItem(page).locator(ICON); + await expect(expectedLabelChild).toBeVisible(); + await expect(stepSequenceDataComponentItem(page)).toHaveCSS( + "color", + "rgb(0, 138, 33)", + ); + }); + + ( + [ + ["current", "rgba(0, 0, 0, 0.9)"], + ["incomplete", "rgba(0, 0, 0, 0.55)"], + ] as [StepSequenceItemProps["status"], string][] + ).forEach(([status, color]) => { + test(`should check hideIndicator prop when status is set to ${status}`, async ({ + mount, + page, + }) => { + await mount(); + const expectedLabelChild = + stepSequenceDataComponentItem(page).locator(ICON); + await expect(expectedLabelChild).not.toBeVisible(); + await expect(stepSequenceDataComponentItem(page)).toHaveCSS( + "color", + color, + ); + }); + }); +}); + +test.describe("Accessibility tests for StepSequence component", () => { + ["horizontal", "vertical"].forEach((orientation) => { + test(`should check orientation is set to ${orientation}`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + }); + + test("should check children for accessibility tests", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + ["-100", "0", "999", testData[0], testData[1]].forEach((indicator) => { + test(`should check indicator is set to ${indicator}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + await checkAccessibility(page); + }); + }); + + testData.forEach((ariaLabel) => { + test(`should check ariaLabel is set to ${ariaLabel}`, async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + }); + + ( + ["complete", "current", "incomplete"] as StepSequenceItemProps["status"][] + ).forEach((status) => { + test(`should check status is set to ${status}`, async ({ mount, page }) => { + await mount(); + await checkAccessibility(page); + }); + }); + + testData.forEach((hiddenCompleteLabel) => { + test(`should check hiddenCompleteLabel is set to ${hiddenCompleteLabel}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + await checkAccessibility(page); + }); + }); + + testData.forEach((hiddenCurrentLabel) => { + test(`should check hiddenCurrentLabel is set to ${hiddenCurrentLabel}`, async ({ + mount, + page, + }) => { + await mount( + , + ); + await checkAccessibility(page); + }); + }); + + ( + ["complete", "current", "incomplete"] as StepSequenceItemProps["status"][] + ).forEach((status) => { + test(`should check hideIndicator prop when status is set to ${status}`, async ({ + mount, + page, + }) => { + await mount(); + await checkAccessibility(page); + }); + }); +}); diff --git a/src/components/step-sequence/step-sequence.stories.tsx b/src/components/step-sequence/step-sequence.stories.tsx new file mode 100644 index 0000000000..a95c35650a --- /dev/null +++ b/src/components/step-sequence/step-sequence.stories.tsx @@ -0,0 +1,242 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; + +import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; +import useMediaQuery from "../../hooks/useMediaQuery"; + +import Box from "../box"; +import { StepSequence, StepSequenceItem } from "."; + +const styledSystemProps = generateStyledSystemProps({ + spacing: true, +}); + +const meta: Meta = { + title: "Step Sequence", + component: StepSequence, + argTypes: { + ...styledSystemProps, + }, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultStory: Story = () => { + return ( + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + + ); +}; +DefaultStory.storyName = "Default"; + +export const Vertical: Story = () => { + return ( + + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + + + ); +}; +Vertical.storyName = "Vertical"; + +export const WithHiddenIndicators: Story = () => { + return ( + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + + ); +}; +WithHiddenIndicators.storyName = "With Hidden Indicators"; + +export const ResponsiveExample: Story = () => { + const displayVertical = useMediaQuery("(max-width: 760px)"); + return ( + + + Name + + + Delivery Address + + + Delivery Details + + + Payment + + + Confirm + + + ); +}; +ResponsiveExample.storyName = "Responsive Example"; +ResponsiveExample.parameters = { chromatic: { viewports: [700] } }; diff --git a/src/components/step-sequence/step-sequence.style.ts b/src/components/step-sequence/step-sequence.style.ts new file mode 100644 index 0000000000..f1af330260 --- /dev/null +++ b/src/components/step-sequence/step-sequence.style.ts @@ -0,0 +1,27 @@ +import styled, { css } from "styled-components"; +import { space, SpaceProps } from "styled-system"; +import { baseTheme } from "../../style/themes"; +import { StepSequenceProps } from "./step-sequence.component"; + +const StyledStepSequence = styled.ol< + Pick & SpaceProps +>` + display: flex; + margin: 0; + font-weight: var(--fontWeights500); + + ${({ orientation }) => + orientation === "vertical" && + css` + flex-direction: column; + height: 100%; + `} + + ${space} +`; + +StyledStepSequence.defaultProps = { + theme: baseTheme, +}; + +export default StyledStepSequence; diff --git a/src/components/step-sequence/step-sequence.test.tsx b/src/components/step-sequence/step-sequence.test.tsx new file mode 100644 index 0000000000..eaa12331b8 --- /dev/null +++ b/src/components/step-sequence/step-sequence.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import { testStyledSystemSpacing } from "../../__spec_helper__/__internal__/test-utils"; +import { StepSequence, StepSequenceItem } from "."; + +test("renders with provided children", () => { + render( + + Step 1 + Step 2 + , + ); + + const steps = within(screen.getByRole("list")).getAllByRole("listitem"); + + expect(steps[0]).toHaveTextContent("Step 1"); + expect(steps[1]).toHaveTextContent("Step 2"); +}); + +test("renders with `orientation` prop set to 'vertical'", () => { + render( + + Step 1 + Step 2 + , + ); + + expect(screen.getByRole("list")).toHaveStyle({ + flexDirection: "column", + height: "100%", + }); +}); + +testStyledSystemSpacing( + (props) => ( + +
test
+
+ ), + () => screen.getByRole("list"), +); From ed49a9eaa50fb53e2075e750bf70b1cdecc587f9 Mon Sep 17 00:00:00 2001 From: Mihai Albu Date: Thu, 5 Dec 2024 09:48:12 +0000 Subject: [PATCH 04/21] fix(vertical-menu-full-screen): register Fullscreen subcomponent as a Carbon modal component is now using useModalManager to register as a modal fixes: #7073 --- .../vertical-menu/components.test-pw.tsx | 100 ++++++++++++++++++ .../vertical-menu-full-screen.component.tsx | 8 ++ .../vertical-menu/vertical-menu.pw.tsx | 28 +++++ .../vertical-menu/vertical-menu.stories.tsx | 100 ++++++++++++++++++ 4 files changed, 236 insertions(+) diff --git a/src/components/vertical-menu/components.test-pw.tsx b/src/components/vertical-menu/components.test-pw.tsx index a2f891f737..450730ed32 100644 --- a/src/components/vertical-menu/components.test-pw.tsx +++ b/src/components/vertical-menu/components.test-pw.tsx @@ -10,6 +10,7 @@ import { VerticalMenuProps, VerticalMenuItemProps, } from "."; +import Confirm from "../confirm/confirm.component"; import Box from "../box"; import Pill from "../pill"; @@ -269,6 +270,105 @@ export const VerticalMenuFullScreenCustom = ( return {menuItems}; }; +export const VerticalMenuFullScreenCustomWithDialog = ( + props: Partial, +) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const fullscreenViewBreakPoint = useMediaQuery("(max-width: 1200px)"); + + const menuItems = ( + <> + + + 10 + + } + /> + + + + + + + + 100 + + } + /> + + 29 + + } + /> + + + + + + + + + + + + + + + + + ); + + if (fullscreenViewBreakPoint) { + return ( + <> + { + setIsMenuOpen(!isMenuOpen); + setIsModalOpen(true); + }} + > + Menu + + setIsMenuOpen(false)} + {...props} + > + {menuItems} + + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + > + Do you want to leave before saving? + + + ); + } + return {menuItems}; +}; + export const VerticalMenuFullScreenBackgroundScrollTest = () => { return ( diff --git a/src/components/vertical-menu/vertical-menu-full-screen/vertical-menu-full-screen.component.tsx b/src/components/vertical-menu/vertical-menu-full-screen/vertical-menu-full-screen.component.tsx index 3b29201a5d..c3aab8cee9 100644 --- a/src/components/vertical-menu/vertical-menu-full-screen/vertical-menu-full-screen.component.tsx +++ b/src/components/vertical-menu/vertical-menu-full-screen/vertical-menu-full-screen.component.tsx @@ -15,6 +15,7 @@ import { } from "../vertical-menu.style"; import VerticalMenuFullScreenContext from "./__internal__/vertical-menu-full-screen.context"; import Events from "../../../__internal__/utils/helpers/events/events"; +import useModalManager from "../../../hooks/__internal__/useModalManager"; export interface VerticalMenuFullScreenProps extends TagProps { /** An aria-label attribute for the menu */ @@ -55,6 +56,13 @@ export const VerticalMenuFullScreen = ({ [onClose], ); + useModalManager({ + open: isOpen, + closeModal: handleKeyDown, + modalRef: menuWrapperRef, + topModalOverride: true, + }); + // TODO remove this as part of FE-5650 if (!isOpen) return null; diff --git a/src/components/vertical-menu/vertical-menu.pw.tsx b/src/components/vertical-menu/vertical-menu.pw.tsx index db2e6dc06d..cd85830f48 100644 --- a/src/components/vertical-menu/vertical-menu.pw.tsx +++ b/src/components/vertical-menu/vertical-menu.pw.tsx @@ -7,6 +7,7 @@ import { VerticalMenuTriggerCustom, VerticalMenuItemCustomHref, VerticalMenuFullScreenCustom, + VerticalMenuFullScreenCustomWithDialog, VerticalMenuFullScreenBackgroundScrollTest, ClosedVerticalMenuFullScreenWithButtons, CustomComponent, @@ -602,6 +603,33 @@ test.describe("Events test", () => { await expect(callbackCount).toBe(1); }); + test(`should be available when a Dialog is opened in the background`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await page.setViewportSize({ + width: 320, + height: 599, + }); + await mount( + { + callbackCount += 1; + }} + />, + ); + + await verticalMenuTrigger(page).click(); + + await closeIconButton(page).click(); + + await expect(callbackCount).toBe(1); + + const dialogText = page.getByText("Do you want to leave before saving?"); + await expect(dialogText).toBeInViewport(); + }); + [...keysToTrigger].forEach((key) => { test(`should call onClose callback when a ${key} key event is triggered`, async ({ mount, diff --git a/src/components/vertical-menu/vertical-menu.stories.tsx b/src/components/vertical-menu/vertical-menu.stories.tsx index 8b0a5549e7..81964939f3 100644 --- a/src/components/vertical-menu/vertical-menu.stories.tsx +++ b/src/components/vertical-menu/vertical-menu.stories.tsx @@ -14,6 +14,8 @@ import { VerticalMenuTrigger, } from "."; +import Confirm from "../confirm/confirm.component"; + const defaultOpenState = isChromatic(); const meta: Meta = { @@ -320,3 +322,101 @@ export const FullScreen: Story = () => { ); }; FullScreen.storyName = "Full Screen"; + +export const FullScreenWithModal: Story = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [disableAutoFocus, setDisableAutoFocus] = useState(false); + + const menuItems = ( + <> + + + 10 + + } + /> + + + + + + + + 100 + + } + /> + + 29 + + } + /> + + + + + + + + + + + + + + + + + ); + + return ( + <> + { + setDisableAutoFocus(true); + setIsModalOpen(true); + setIsMenuOpen(true); + }} + > + Menu + + { + setIsMenuOpen(false); + setDisableAutoFocus(false); + }} + > + {menuItems} + + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + > + Do you want to leave before saving?? + + + ); +}; +FullScreenWithModal.storyName = "Full Screen With Modal"; From bd252b701b991b3092effeffb15dfb2067637948 Mon Sep 17 00:00:00 2001 From: DipperTheDan Date: Thu, 5 Dec 2024 13:45:04 +0000 Subject: [PATCH 05/21] chore(focus-trap): address spelling mistake in code comment --- src/__internal__/focus-trap/focus-trap.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__internal__/focus-trap/focus-trap.component.tsx b/src/__internal__/focus-trap/focus-trap.component.tsx index 751d2e7998..8432ba6b42 100644 --- a/src/__internal__/focus-trap/focus-trap.component.tsx +++ b/src/__internal__/focus-trap/focus-trap.component.tsx @@ -279,7 +279,7 @@ const FocusTrap = ({ onFocus: updateCurrentFocusedElement, }); - // passes focusProps, sets tabIndex and onBlur if no tabIndex has been expicitly set on child + // passes focusProps, sets tabIndex and onBlur if no tabIndex has been explicitly set on child const clonedChildren = React.Children.map(children, (child) => { const focusableChild = child as React.ReactElement; return React.cloneElement( From a843fe8be5df9ad728f6ee235c3e2c848808711c Mon Sep 17 00:00:00 2001 From: DipperTheDan Date: Thu, 5 Dec 2024 13:48:04 +0000 Subject: [PATCH 06/21] fix(popover-container): ensure that tab sequence is not lost when container has radio buttons This fix ensures that if the last focusable element in a popover container is a radio button, that the tab sequence is not affected once focus is outside of the container fixes #7067 --- .../popover-container/components.test-pw.tsx | 29 +++++++ .../popover-container-test.stories.tsx | 31 +++++++ .../popover-container.component.tsx | 51 ++++++++++- .../popover-container.pw.tsx | 20 +++++ .../popover-container.test.tsx | 85 +++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) diff --git a/src/components/popover-container/components.test-pw.tsx b/src/components/popover-container/components.test-pw.tsx index 4c624a366f..646437080d 100644 --- a/src/components/popover-container/components.test-pw.tsx +++ b/src/components/popover-container/components.test-pw.tsx @@ -7,6 +7,7 @@ import Pill from "../pill"; import Badge from "../badge"; import Box from "../box"; import { Select, Option } from "../select"; +import RadioButton, { RadioButtonGroup } from "../radio-button"; import PopoverContainer, { PopoverContainerProps, } from "./popover-container.component"; @@ -434,3 +435,31 @@ export const PopoverContainerFocusOrder = ( ); }; + +export const WithRadioButtons = () => { + const [open1, setOpen1] = useState(false); + return ( + + setOpen1(true)} + onClose={() => setOpen1(false)} + open={open1} + renderOpenComponent={({ ref, onClick }) => ( + + )} + p={0} + > + + + + + + + + + + ); +}; diff --git a/src/components/popover-container/popover-container-test.stories.tsx b/src/components/popover-container/popover-container-test.stories.tsx index 2286ca54f6..338e758cea 100644 --- a/src/components/popover-container/popover-container-test.stories.tsx +++ b/src/components/popover-container/popover-container-test.stories.tsx @@ -11,6 +11,7 @@ import Typography from "../typography"; import Search from "../search"; import IconButton from "../icon-button"; import Icon from "../icon"; +import RadioButton, { RadioButtonGroup } from "../radio-button"; export default { title: "Popover Container/Test", @@ -22,6 +23,7 @@ export default { "InsideMenu", "InsideMenuWithOpenButton", "WithFullWidthButton", + "WithRadioButtons", ], parameters: { info: { disable: true }, @@ -246,3 +248,32 @@ export const WithFullWidthButton = () => { ); }; + +export const WithRadioButtons = () => { + const [open1, setOpen1] = useState(false); + + return ( + + setOpen1(true)} + onClose={() => setOpen1(false)} + open={open1} + renderOpenComponent={({ ref, onClick }) => ( + + )} + p={0} + > + + + + + + + + + + ); +}; diff --git a/src/components/popover-container/popover-container.component.tsx b/src/components/popover-container/popover-container.component.tsx index 5889c3b4ae..5d707157de 100644 --- a/src/components/popover-container/popover-container.component.tsx +++ b/src/components/popover-container/popover-container.component.tsx @@ -30,6 +30,7 @@ import useFocusPortalContent from "../../hooks/__internal__/useFocusPortalConten import tagComponent, { TagProps, } from "../../__internal__/utils/helpers/tags/tags"; +import { defaultFocusableSelectors } from "../../__internal__/focus-trap/focus-trap-utils"; export interface RenderOpenProps { tabIndex: number; @@ -272,6 +273,38 @@ export const PopoverContainer = ({ closePopover, ); + const onFocusNextElement = useCallback((ev) => { + const allFocusableElements: HTMLElement[] = Array.from( + document.querySelectorAll(defaultFocusableSelectors) || + /* istanbul ignore next */ [], + ); + const filteredElements = allFocusableElements.filter( + (el) => el === openButtonRef.current || Number(el.tabIndex) !== -1, + ); + + const openButtonRefIndex = filteredElements.indexOf( + openButtonRef.current as HTMLElement, + ); + + filteredElements[openButtonRefIndex + 1].focus(); + closePopover(ev); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleFocusGuard = ( + direction: "prev" | "next", + ev: React.FocusEvent, + ) => { + if (direction === "next" && onFocusNextElement) { + // Focus the next focusable element outside of the popover + onFocusNextElement(ev); + return; + } + + // istanbul ignore else + if (direction === "prev") openButtonRef.current?.focus(); + }; + const renderOpenComponentProps = { tabIndex: 0, "aria-expanded": isOpen, @@ -335,7 +368,23 @@ export const PopoverContainer = ({ ) : ( - popover() + <> +
handleFocusGuard("prev", ev)} + /> + {popover()} +
handleFocusGuard("next", ev)} + /> + ); return ( diff --git a/src/components/popover-container/popover-container.pw.tsx b/src/components/popover-container/popover-container.pw.tsx index 1b939d41fb..1021f0e5ce 100644 --- a/src/components/popover-container/popover-container.pw.tsx +++ b/src/components/popover-container/popover-container.pw.tsx @@ -34,6 +34,7 @@ import { WithRenderCloseButtonComponent, PopoverContainerComponentCoverButton, PopoverContainerFocusOrder, + WithRadioButtons, } from "../popover-container/components.test-pw"; import Portrait from "../portrait"; @@ -624,6 +625,25 @@ test.describe("Check props of Popover Container component", () => { await expect(third).toBeFocused(); }); + test("should focus the next focusable element outside of the container once finished keyboard navigating through the container's content", async ({ + mount, + page, + }) => { + await mount(); + + const openButton = page.getByRole("button", { name: "open" }); + const container = popoverContainerContent(page); + const additionalButton = page.getByRole("button", { name: "foo" }); + + await openButton.click(); + await page.keyboard.press("Tab"); // focus on first radio button + await page.keyboard.press("Tab"); // focus on close icon + await page.keyboard.press("Tab"); // focus outside of container and on to additional button + + await expect(container).not.toBeVisible(); + await expect(additionalButton).toBeFocused(); + }); + test.describe("Accessibility tests", () => { test("should check accessibility for Default example", async ({ mount, diff --git a/src/components/popover-container/popover-container.test.tsx b/src/components/popover-container/popover-container.test.tsx index e5efb70905..60b8eff0db 100644 --- a/src/components/popover-container/popover-container.test.tsx +++ b/src/components/popover-container/popover-container.test.tsx @@ -12,6 +12,8 @@ import { testStyledSystemPadding } from "../../__spec_helper__/__internal__/test import PopoverContainer from "./popover-container.component"; import { Select, Option } from "../select"; import useMediaQuery from "../../hooks/useMediaQuery"; +import Button from "../button"; +import RadioButton, { RadioButtonGroup } from "../radio-button"; jest.mock("../../hooks/useMediaQuery"); @@ -531,6 +533,89 @@ describe("closing the popup", () => { }); }); +test("when content is navigated via keyboard, the next focusable item should be focused and popup closed", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + <> + ( + + )} + > + + + + + + + , + ); + + const openButton = screen.getByRole("button", { name: "open button" }); + await user.click(openButton); + await user.tab(); // tab to close icon + await user.tab(); // tab to RadioButtonGroup + await user.tab(); // tab to Example Button (outside of popup) + + const popup = await screen.findByRole("dialog"); + await waitFor(() => expect(popup).not.toBeVisible()); + + const exampleButton = screen.getByRole("button", { name: "Example Button" }); + expect(exampleButton).toHaveFocus(); +}); + +test("when the popover is opened and shift tab key is pressed, the open button should be focused and popup closed", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + <> + ( + + )} + > + + + + + + + , + ); + + const openButton = screen.getByRole("button", { name: "open button" }); + await user.click(openButton); + await user.tab(); // tab to close icon + await user.tab(); // tab to content + await user.tab({ shift: true }); // shift tab back to close icon + await user.tab({ shift: true }); // shift tab back to the opening trigger element + + const popup = await screen.findByRole("dialog"); + await waitFor(() => expect(popup).not.toBeVisible()); + expect(openButton).toHaveFocus(); +}); + +test("if only the open trigger is the only focusable element on screen, when the popover is opened and tab key is used to navigate content, it should navigate back to the opening trigger", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + <> + + , + ); + + const openButton = screen.getByRole("button", { name: "My popup" }); + await user.click(openButton); + await user.tab(); // tab to close icon + await user.tab(); // tab back out of content to the opening trigger element + + expect(openButton).toHaveFocus(); +}); + testStyledSystemPadding( (props) => ( From af6e310e7df3b7e89e1d06f8ae7207fad97f615f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 5 Dec 2024 14:08:42 +0000 Subject: [PATCH 07/21] chore(release): 144.12.1 ### [144.12.1](https://github.com/Sage/carbon/compare/v144.12.0...v144.12.1) (2024-12-05) ### Bug Fixes * **popover-container:** ensure that tab sequence is not lost when container has radio buttons ([a843fe8](https://github.com/Sage/carbon/commit/a843fe8be5df9ad728f6ee235c3e2c848808711c)), closes [#7067](https://github.com/Sage/carbon/issues/7067) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a6115647..c3c13ed054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +### [144.12.1](https://github.com/Sage/carbon/compare/v144.12.0...v144.12.1) (2024-12-05) + + +### Bug Fixes + +* **popover-container:** ensure that tab sequence is not lost when container has radio buttons ([a843fe8](https://github.com/Sage/carbon/commit/a843fe8be5df9ad728f6ee235c3e2c848808711c)), closes [#7067](https://github.com/Sage/carbon/issues/7067) + ## [144.12.0](https://github.com/Sage/carbon/compare/v144.11.0...v144.12.0) (2024-12-04) diff --git a/package-lock.json b/package-lock.json index 4e71fd08d5..7dd7a5ca75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "carbon-react", - "version": "144.12.0", + "version": "144.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "carbon-react", - "version": "144.12.0", + "version": "144.12.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f408be65f6..ebf78fee79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carbon-react", - "version": "144.12.0", + "version": "144.12.1", "description": "A library of reusable React components for easily building user interfaces.", "files": [ "lib", From e217e2185a45a33d8bb1af0a156339a870f51159 Mon Sep 17 00:00:00 2001 From: Damien Robson Date: Tue, 12 Nov 2024 08:08:49 +0000 Subject: [PATCH 08/21] feat(date-input, date-range): upgrade react-day-picker to v9 Upgrades react-day-picker to remove the dependency on React 18 --- package-lock.json | 50 +- package.json | 2 +- playwright/components/date-input/index.ts | 3 +- playwright/components/date-input/locators.ts | 4 +- src/__internal__/utils/logger/logger.test.ts | 2 +- .../date/__internal__/date-fns-fp/index.ts | 2 + .../date-picker/date-picker.component.tsx | 252 ++++----- .../date-picker/date-picker.test.tsx | 112 ++-- .../date-picker/day-picker.style.ts | 477 +++++++++++------- .../__internal__/navbar/navbar.component.tsx | 10 +- .../date/__internal__/navbar/navbar.style.ts | 2 +- .../date/__internal__/utils.test.ts | 62 +++ src/components/date/__internal__/utils.ts | 24 +- .../__internal__/weekday/weekday.style.ts | 6 +- .../__internal__/weekday/weekday.test.tsx | 18 +- src/components/date/date.component.tsx | 18 +- src/components/date/date.mdx | 6 + src/components/date/date.pw.tsx | 247 ++++----- src/components/date/date.stories.tsx | 20 + src/components/date/date.test.tsx | 18 +- 20 files changed, 862 insertions(+), 473 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7dd7a5ca75..ab6cba69e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "lodash": "^4.17.21", "polished": "^4.2.2", "prop-types": "^15.8.1", - "react-day-picker": "~7.4.10", + "react-day-picker": "^9.3.2", "react-dnd": "^15.1.2", "react-dnd-html5-backend": "^15.1.3", "react-is": "^17.0.2", @@ -2695,6 +2695,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -4548,17 +4554,20 @@ "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", - "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==" + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.1.tgz", - "integrity": "sha512-blqduwV86oiKw2Gr44wbe3pj3Z/OsXirc7ybCv9F/pLAR+Aih8F3rjeJzK0ANgtYKv5lCpkGVoZAeKitKDaD/g==" + "integrity": "sha512-blqduwV86oiKw2Gr44wbe3pj3Z/OsXirc7ybCv9F/pLAR+Aih8F3rjeJzK0ANgtYKv5lCpkGVoZAeKitKDaD/g==", + "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.1.tgz", - "integrity": "sha512-XjDVbs3ZU16CO1h5Q3Ew2RPJqmZBDE/EVf1LYp6ePEffs3V/MX9ZbL5bJr8qiK5SbGmUMuDoaFgyKacYz8prRA==" + "integrity": "sha512-XjDVbs3ZU16CO1h5Q3Ew2RPJqmZBDE/EVf1LYp6ePEffs3V/MX9ZbL5bJr8qiK5SbGmUMuDoaFgyKacYz8prRA==", + "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.24.0", @@ -10782,6 +10791,7 @@ "version": "15.1.2", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.2.tgz", "integrity": "sha512-EOec1LyJUuGRFg0LDa55rSRAUe97uNVKVkUo8iyvzQlcECYTuPblVQfRWXWj1OyPseFIeebWpNmKFy0h6BcF1A==", + "license": "MIT", "dependencies": { "@react-dnd/asap": "4.0.1", "@react-dnd/invariant": "3.0.1", @@ -24215,20 +24225,40 @@ } }, "node_modules/react-day-picker": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-7.4.10.tgz", - "integrity": "sha512-/QkK75qLKdyLmv0kcVzhL7HoJPazoZXS8a6HixbVoK6vWey1Od1WRLcxfyEiUsRfccAlIlf6oKHShqY2SM82rA==", + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.3.2.tgz", + "integrity": "sha512-Rj2gPPVYKqZbSF8DxaLteHY+45zd6swf5yE3hmJ8m6VEqPI2ve9CuZsDvQ10tIt3ckRJ9hmLa5t0SsmLlXllhw==", + "license": "MIT", "dependencies": { - "prop-types": "^15.6.2" + "@date-fns/tz": "^1.2.0", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" }, "peerDependencies": { - "react": "~0.13.x || ~0.14.x || ^15.0.0 || ^16.0.0 || ^17.0.0" + "react": ">=16.8.0" + } + }, + "node_modules/react-day-picker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/react-dnd": { "version": "15.1.2", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.2.tgz", "integrity": "sha512-EaSbMD9iFJDY/o48T3c8wn3uWU+2uxfFojhesZN3LhigJoAIvH2iOjxofSA9KbqhAKP6V9P853G6XG8JngKVtA==", + "license": "MIT", "dependencies": { "@react-dnd/invariant": "3.0.1", "@react-dnd/shallowequal": "3.0.1", @@ -24258,6 +24288,7 @@ "version": "15.1.3", "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.3.tgz", "integrity": "sha512-HH/8nOEmrrcRGHMqJR91FOwhnLlx5SRLXmsQwZT3IPcBjx88WT+0pWC5A4tDOYDdoooh9k+KMPvWfxooR5TcOA==", + "license": "MIT", "dependencies": { "dnd-core": "15.1.2" } @@ -25283,6 +25314,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.9.2" } diff --git a/package.json b/package.json index ebf78fee79..f798566953 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,7 @@ "lodash": "^4.17.21", "polished": "^4.2.2", "prop-types": "^15.8.1", - "react-day-picker": "~7.4.10", + "react-day-picker": "^9.3.2", "react-dnd": "^15.1.2", "react-dnd-html5-backend": "^15.1.3", "react-is": "^17.0.2", diff --git a/playwright/components/date-input/index.ts b/playwright/components/date-input/index.ts index 187fd6024b..b4cf96625d 100644 --- a/playwright/components/date-input/index.ts +++ b/playwright/components/date-input/index.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; + import { DAY_PICKER_WRAPPER, DAY_PICKER_HEADING } from "./locators"; // component preview locators @@ -6,4 +7,4 @@ import { DAY_PICKER_WRAPPER, DAY_PICKER_HEADING } from "./locators"; export const dayPickerWrapper = (page: Page) => page.locator(DAY_PICKER_WRAPPER); export const dayPickerHeading = (page: Page) => - page.locator(DAY_PICKER_HEADING).locator("div"); + page.locator(DAY_PICKER_HEADING); diff --git a/playwright/components/date-input/locators.ts b/playwright/components/date-input/locators.ts index 7e4ea8af56..7dd736eea1 100644 --- a/playwright/components/date-input/locators.ts +++ b/playwright/components/date-input/locators.ts @@ -1,3 +1,3 @@ // component preview locators -export const DAY_PICKER_WRAPPER = 'div[class="DayPicker-wrapper"]'; -export const DAY_PICKER_HEADING = ".DayPicker-Caption"; +export const DAY_PICKER_WRAPPER = 'div[class="rdp-root"]'; +export const DAY_PICKER_HEADING = 'span[class="rdp-caption_label"]'; diff --git a/src/__internal__/utils/logger/logger.test.ts b/src/__internal__/utils/logger/logger.test.ts index cb3df963fa..28ec231ca7 100644 --- a/src/__internal__/utils/logger/logger.test.ts +++ b/src/__internal__/utils/logger/logger.test.ts @@ -1,6 +1,6 @@ import Logger from "."; -test("should not output a warning to the console when logging is disabled", () => { +test("should not output a warning to the console when logging is disabled and a deprecation message is fired", () => { Logger.setEnabledState(false); const consoleWarnSpy = jest .spyOn(console, "warn") diff --git a/src/components/date/__internal__/date-fns-fp/index.ts b/src/components/date/__internal__/date-fns-fp/index.ts index b815474310..2b65e25e17 100644 --- a/src/components/date/__internal__/date-fns-fp/index.ts +++ b/src/components/date/__internal__/date-fns-fp/index.ts @@ -3,5 +3,7 @@ export { default as format } from "date-fns/fp/format"; export { default as formatISO } from "date-fns/fp/formatISO"; export { default as isMatch } from "date-fns/fp/isMatch"; +export { default as isValid } from "date-fns/fp/isValid"; export { default as parse } from "date-fns/fp/parse"; +export { default as parseWithOptions } from "date-fns/fp/parseWithOptions"; export { default as parseISO } from "date-fns/fp/parseISO"; 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 d975644df4..c5b640126f 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -1,37 +1,41 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; -import DayPicker, { +import { flip, offset } from "@floating-ui/dom"; +import React, { + useCallback, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + DayPicker, DayPickerProps, - DayModifiers, - Modifier, - LocaleUtils, + defaultLocale, + Modifiers, } from "react-day-picker"; -import { flip, offset } from "@floating-ui/dom"; -import { getDisabledDays } from "../utils"; -import Popover from "../../../../__internal__/popover"; import useLocale from "../../../../hooks/__internal__/useLocale"; +import Popover from "../../../../__internal__/popover"; import Navbar from "../navbar"; import Weekday from "../weekday"; -import StyledDayPicker from "./day-picker.style"; -import Events from "../../../../__internal__/utils/helpers/events"; +import { getDisabledDays } from "../utils"; import { defaultFocusableSelectors } from "../../../../__internal__/focus-trap/focus-trap-utils"; +import Events from "../../../../__internal__/utils/helpers/events"; + +import StyledDayPicker from "./day-picker.style"; type CustomRefObject = { current?: T | null; }; -/** there is an issue with typescript-to-proptypes package that means we need to override these types */ -interface Modifiers { - today: NonNullable | NonNullable[]; - outside: NonNullable | NonNullable[]; - [other: string]: NonNullable | NonNullable[]; -} - export interface PickerProps - extends Omit { - disabledDays?: NonNullable | NonNullable[] | undefined[]; + extends Omit< + DayPickerProps, + "mode" | "disabledDays" | "modifiers" | "selectedDays" + > { + disabledDays?: NonNullable | NonNullable[] | undefined[]; modifiers?: Partial; - selectedDays?: NonNullable | NonNullable[] | undefined[]; + selectedDays?: NonNullable | NonNullable[] | undefined[]; } export interface DatePickerProps { @@ -46,7 +50,7 @@ export interface DatePickerProps { /** Element that the DatePicker will be displayed under */ inputElement: CustomRefObject; /** Currently selected date */ - selectedDays?: Date; + selectedDays?: Date | undefined; /** Callback to handle mousedown event on picker container */ pickerMouseDown?: () => void; /** Sets whether the picker should be displayed */ @@ -82,6 +86,9 @@ export const DatePicker = ({ pickerTabGuardId, onPickerClose, }: DatePickerProps) => { + const [focusedMonth, setFocusedMonth] = useState( + selectedDays || new Date(), + ); const locale = useLocale(); const { localize, options } = locale.date.dateFnsLocale(); const { weekStartsOn } = options || /* istanbul ignore next */ {}; @@ -93,13 +100,6 @@ export const DatePicker = ({ }), [localize], ); - const monthsShort = useMemo( - () => - Array.from({ length: 12 }).map((_, i) => - localize?.month(i, { width: "abbreviated" }).substring(0, 3), - ), - [localize], - ); const weekdaysLong = useMemo( () => Array.from({ length: 7 }).map((_, i) => localize?.day(i)), [localize], @@ -119,67 +119,33 @@ export const DatePicker = ({ }, [locale, localize]); const ref = useRef(null); - useEffect(() => { - if (open) { - // this is a temporary fix for some axe issues that are baked into the library we use for the picker - const captionElement = ref.current?.querySelector(".DayPicker-Caption"); - /* istanbul ignore else */ - if (captionElement) { - captionElement.removeAttribute("role"); - captionElement.removeAttribute("aria-live"); - } - - // focus the selected or today's date first - const selectedDay = - ref.current?.querySelector(".DayPicker-Day--selected") || - ref.current?.querySelector(".DayPicker-Day--today"); - const firstDay = ref.current?.querySelector( - ".DayPicker-Day[tabindex='0']", - ); - - /* istanbul ignore else */ - if (selectedDay && firstDay !== selectedDay) { - selectedDay?.setAttribute("tabindex", "0"); - firstDay?.setAttribute("tabindex", "-1"); - } - } - }, [open]); - const handleDayClick = ( - date: Date, - modifiers: DayModifiers, - ev: React.MouseEvent, + date?: Date, + e?: React.MouseEvent, ) => { - if (!modifiers.disabled) { - const { id, name } = inputElement?.current - ?.firstChild as HTMLInputElement; - ev.target = { - ...ev.target, - id, - name, - } as HTMLInputElement; - onDayClick?.(date, ev); - onPickerClose?.(); - } + /* istanbul ignore else */ + if (date) onDayClick?.(date, e as React.MouseEvent); + onPickerClose?.(); }; const handleKeyUp = useCallback( - (ev) => { + (ev: KeyboardEvent) => { /* istanbul ignore else */ if (open && Events.isEscKey(ev)) { + setFocusedMonth(selectedDays); inputElement.current?.querySelector("input")?.focus(); setOpen(false); onPickerClose?.(); ev.stopPropagation(); } }, - [inputElement, onPickerClose, open, setOpen], + [inputElement, onPickerClose, open, selectedDays, setOpen], ); const handleOnKeyDown = (ev: React.KeyboardEvent) => { /* istanbul ignore else */ if ( - ref.current?.querySelector(".DayPicker-NavBar button") === + ref.current?.querySelector(".rdp-nav button") === document.activeElement && Events.isTabKey(ev) && Events.isShiftKey(ev) @@ -193,7 +159,7 @@ export const DatePicker = ({ const handleOnDayKeyDown = ( _day: Date, - _modifiers: DayModifiers, + _modifiers: Modifiers, ev: React.KeyboardEvent, ) => { // we need to manually handle this as the picker may be in a Portal @@ -223,66 +189,112 @@ export const DatePicker = ({ } }; - const formatDay = (date: Date) => - `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ - monthsShort[date.getMonth()] - } ${date.getFullYear()}`; + useEffect(() => { + if (selectedDays) { + setFocusedMonth(selectedDays); + } + }, [selectedDays]); + + useEffect(() => { + if (!open && selectedDays) { + const fMonth = focusedMonth?.getMonth(); + const sMonth = selectedDays?.getMonth(); + if (fMonth !== sMonth) setFocusedMonth(selectedDays); + } + }, [focusedMonth, open, selectedDays]); if (!open) { return null; } - const localeUtils = { formatDay } as LocaleUtils; - const handleTabGuardFocus = () => { ref.current?.querySelector("button")?.focus(); }; return ( - - + -
- { - const { className, weekday } = weekdayElementProps; + +
+ + `${localize?.month(month.getMonth())} ${month.getFullYear()}`, + }} + required={false} + weekStartsOn={weekStartsOn} + onMonthChange={setFocusedMonth} + disabled={getDisabledDays(minDate, maxDate)} + locale={{ + localize: { + ...defaultLocale.localize, + months: monthsLong, + weekdaysLong, + weekdaysShort, + }, + }} + selected={focusedMonth} + month={focusedMonth || /* istanbul ignore next */ new Date()} + onDayClick={(d, _, e) => { + const date = d as Date; + handleDayClick(date, e); + }} + components={{ + Nav: (props) => { + return ; + }, + Weekday: (props) => { + const fixedDays = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + }; + const { className, "aria-label": ariaLabel } = props; + const dayIndex = fixedDays[ariaLabel as keyof typeof fixedDays]; - return ( - - {weekdaysShort[weekday]} - - ); - }} - navbarElement={} - fixedWeeks - initialMonth={selectedDays || undefined} - disabledDays={getDisabledDays(minDate, maxDate)} - locale={locale.locale()} - localeUtils={localeUtils} - onDayKeyDown={handleOnDayKeyDown} - {...pickerProps} - /> - - + return ( + + {weekdaysShort[dayIndex]} + + ); + }, + }} + fixedWeeks + defaultMonth={selectedDays || undefined} + onDayKeyDown={(date, modifiers, e) => { + handleOnDayKeyDown( + date, + modifiers, + e as React.KeyboardEvent, + ); + }} + {...pickerProps} + showOutsideDays + mode="single" + /> + + + ); }; 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 fbb0f2a94b..7d4d234987 100644 --- a/src/components/date/__internal__/date-picker/date-picker.test.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.test.tsx @@ -1,7 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; - import enGBLocale from "date-fns/locale/en-GB"; import deLocale from "date-fns/locale/de"; import esLocale from "date-fns/locale/es"; @@ -10,6 +9,7 @@ import enZALocale from "date-fns/locale/en-ZA"; import frLocale from "date-fns/locale/fr"; import frCALocale from "date-fns/locale/fr-CA"; import enUSLocale from "date-fns/locale/en-US"; +import zhCNLocale from "date-fns/locale/zh-CN"; import DatePicker, { DatePickerProps } from "./date-picker.component"; import I18nProvider from "../../../i18n-provider"; @@ -22,7 +22,7 @@ const DatePickerWithInput = (props: MockProps) => { const ref = React.useRef(null); const Input = () => (
- +
); return ( @@ -60,9 +60,15 @@ test("should render the day element that matches the `selectedDate` when prop is open />, ); - const selectedDay = screen.getByRole("gridcell", { name: "Thu 4 Apr 2019" }); - expect(selectedDay).toHaveAttribute("aria-selected", "true"); + const selectedDay = screen.getByLabelText("Thursday, April 4th, 2019", { + exact: false, + }); + + expect(selectedDay).toHaveAttribute( + "aria-label", + "Thursday, April 4th, 2019, selected", + ); }); test("should render the expected weekday with `aria-disabled=true` attribute when `minDate` is '2019-04-02'", () => { @@ -74,11 +80,11 @@ test("should render the expected weekday with `aria-disabled=true` attribute whe open />, ); - const disabledDay = screen.getByRole("gridcell", { name: "Mon 1 Apr 2019" }); - const activeDay = screen.getByRole("gridcell", { name: "Tue 2 Apr 2019" }); + const disabledDay = screen.getByLabelText("Monday, April 1st, 2019"); + const activeDay = screen.getByLabelText("Tuesday, April 2nd, 2019"); - expect(disabledDay).toHaveAttribute("aria-disabled", "true"); - expect(activeDay).toHaveAttribute("aria-disabled", "false"); + expect(disabledDay).toBeDisabled(); + expect(activeDay).toBeEnabled(); }); test("should not render any of the current month's weekdays with `aria-disabled=true` attribute when `minDate` is invalid", () => { @@ -91,12 +97,10 @@ test("should not render any of the current month's weekdays with `aria-disabled= />, ); // need to filter out the weekdays that are not in the current month - const currentMonthDays = screen.getAllByRole("gridcell", { - name: new RegExp("Apr", "i"), - }); + const currentMonthDays = screen.getAllByLabelText("April", { exact: false }); currentMonthDays.forEach((day) => { - expect(day).toHaveAttribute("aria-disabled", "false"); + expect(day).toBeEnabled(); }); }); @@ -110,12 +114,10 @@ test("should not render any of the current month's weekdays with `aria-disabled= />, ); // need to filter out the weekdays that are not in the current month - const currentMonthDays = screen.getAllByRole("gridcell", { - name: new RegExp("Apr", "i"), - }); + const currentMonthDays = screen.getAllByLabelText("April", { exact: false }); currentMonthDays.forEach((day) => { - expect(day).toHaveAttribute("aria-disabled", "false"); + expect(day).toBeEnabled(); }); }); @@ -128,11 +130,11 @@ test("should render the expected weekday with `aria-disabled=true` attribute whe open />, ); - const disabledDay = screen.getByRole("gridcell", { name: "Sat 6 Apr 2019" }); - const activeDay = screen.getByRole("gridcell", { name: "Fri 5 Apr 2019" }); + const disabledDay = screen.getByLabelText("Saturday, April 6th, 2019"); + const activeDay = screen.getByLabelText("Friday, April 5th, 2019"); - expect(disabledDay).toHaveAttribute("aria-disabled", "true"); - expect(activeDay).toHaveAttribute("aria-disabled", "false"); + expect(disabledDay).toBeDisabled(); + expect(activeDay).toBeEnabled(); }); test("should not render any of the current month's weekdays with `aria-disabled=true` attribute when `maxDate` is invalid", () => { @@ -145,12 +147,10 @@ test("should not render any of the current month's weekdays with `aria-disabled= />, ); // need to filter out the weekdays that are not in the current month - const currentMonthDays = screen.getAllByRole("gridcell", { - name: new RegExp("Apr", "i"), - }); + const currentMonthDays = screen.getAllByLabelText("April", { exact: false }); currentMonthDays.forEach((day) => { - expect(day).toHaveAttribute("aria-disabled", "false"); + expect(day).toBeEnabled(); }); }); @@ -164,12 +164,10 @@ test("should not render any of the current month's weekdays with `aria-disabled= />, ); // need to filter out the weekdays that are not in the current month - const currentMonthDays = screen.getAllByRole("gridcell", { - name: new RegExp("Apr", "i"), - }); + const currentMonthDays = screen.getAllByLabelText("April", { exact: false }); currentMonthDays.forEach((day) => { - expect(day).toHaveAttribute("aria-disabled", "false"); + expect(day).toBeEnabled(); }); }); @@ -185,7 +183,7 @@ test("should not call `onDayClick` callback when a user clicks a disabled day", onDayClick={onDayClick} />, ); - const disabledDay = screen.getByRole("gridcell", { name: "Mon 1 Apr 2019" }); + const disabledDay = screen.getByLabelText("Monday, April 1st, 2019"); await user.click(disabledDay); expect(onDayClick).not.toHaveBeenCalled(); @@ -202,7 +200,7 @@ test("should call `onDayClick` callback when a user clicks a day that is not dis onDayClick={onDayClick} />, ); - const activeDay = screen.getByRole("gridcell", { name: "Tue 2 Apr 2019" }); + const activeDay = screen.getByLabelText("Tuesday, April 2nd, 2019"); await user.click(activeDay); expect(onDayClick).toHaveBeenCalled(); @@ -402,3 +400,57 @@ test("should render with 'fr-CA' translations when the `locale` is passed via I1 expect(screen.getByRole("button", { name: "fr-CA-previous" })).toBeVisible(); expect(screen.getByRole("button", { name: "fr-CA-next" })).toBeVisible(); }); + +test("should correctly translate the month caption for the given locale (fr-FR)", () => { + render( + "fr-FR", + date: { + dateFnsLocale: () => frLocale, + ariaLabels: { + previousMonthButton: () => "fr-FR-previous", + nextMonthButton: () => "fr-FR-next", + }, + }, + }} + > + {}} + open + selectedDays={new Date(2019, 2, 4)} + /> + , + ); + + const monthCaption = screen.getByRole("status"); + expect(monthCaption).toBeVisible(); + expect(monthCaption).toHaveTextContent("mars 2019"); +}); + +test("should correctly translate the month caption for the given locale (zh-CN)", () => { + render( + "zh-CN", + date: { + dateFnsLocale: () => zhCNLocale, + ariaLabels: { + previousMonthButton: () => "zh-CN-previous", + nextMonthButton: () => "zh-CN-next", + }, + }, + }} + > + {}} + open + selectedDays={new Date(2019, 2, 4)} + /> + , + ); + + const monthCaption = screen.getByRole("status"); + expect(monthCaption).toBeVisible(); + expect(monthCaption).toHaveTextContent("三月 2019"); +}); diff --git a/src/components/date/__internal__/date-picker/day-picker.style.ts b/src/components/date/__internal__/date-picker/day-picker.style.ts index 3fff700e4b..c745529c86 100644 --- a/src/components/date/__internal__/date-picker/day-picker.style.ts +++ b/src/components/date/__internal__/date-picker/day-picker.style.ts @@ -1,4 +1,5 @@ import styled, { css } from "styled-components"; + import baseTheme from "../../../../style/themes/base"; import addFocusStyling from "../../../../style/utils/add-focus-styling"; @@ -6,196 +7,308 @@ const oldFocusStyling = ` outline: solid 3px var(--colorsSemanticFocus500); `; -// Styles copied from https://github.com/gpbl/react-day-picker/blob/v6.1.1/src/style.css -const addReactDayPickerStyles = () => ` - .DayPicker { - display: inline-block; +const officialReactDayPickerStyling = () => css` + /* Variables declaration */ + /* prettier-ignore */ + .rdp-root { + --rdp-accent-color: blue; /* The accent color used for selected days and UI elements. */ + --rdp-accent-background-color: #f0f0ff; /* The accent background color used for selected days and UI elements. */ + + --rdp-day-height: 2.75rem; /* The height of the day cells. */ + --rdp-day-width: 2.75rem; /* The width of the day cells. */ + + --rdp-day_button-border-radius: 100%; /* The border radius of the day cells. */ + --rdp-day_button-border: 2px solid transparent; /* The border of the day cells. */ + --rdp-day_button-height: var(--rdp-day-height); /* The height of the day cells. */ + --rdp-day_button-width: var(--rdp-day-width); /* The width of the day cells. */ + + --rdp-selected-border: 2px solid var(--rdp-accent-color); /* The border of the selected days. */ + --rdp-disabled-opacity: 0.5; /* The opacity of the disabled days. */ + --rdp-outside-opacity: 0.75; /* The opacity of the days outside the current month. */ + --rdp-today-color: var(--rdp-accent-color); /* The color of the today's date. */ + + --rdp-dropdown-gap: 0.5rem;/* The gap between the dropdowns used in the month captons. */ + + --rdp-months-gap: 2rem; /* The gap between the months in the multi-month view. */ + + --rdp-nav_button-disabled-opacity: 0.5; /* The opacity of the disabled navigation buttons. */ + --rdp-nav_button-height: 2.25rem; /* The height of the navigation buttons. */ + --rdp-nav_button-width: 2.25rem; /* The width of the navigation buttons. */ + --rdp-nav-height: 2.75rem; /* The height of the navigation bar. */ + + --rdp-range_middle-background-color: var(--rdp-accent-background-color); /* The color of the background for days in the middle of a range. */ + --rdp-range_middle-foreground-color: white; /* The foregraound color for days in the middle of a range. */ + --rdp-range_middle-color: inherit;/* The color of the range text. */ + + --rdp-range_start-color: white; /* The color of the range text. */ + --rdp-range_start-background: linear-gradient(var(--rdp-gradient-direction), transparent 50%, var(--rdp-range_middle-background-color) 50%); /* Used for the background of the start of the selected range. */ + --rdp-range_start-date-background-color: var(--rdp-accent-color); /* The background color of the date when at the start of the selected range. */ + + --rdp-range_end-background: linear-gradient(var(--rdp-gradient-direction), var(--rdp-range_middle-background-color) 50%, transparent 50%); /* Used for the background of the end of the selected range. */ + --rdp-range_end-color: white;/* The color of the range text. */ + --rdp-range_end-date-background-color: var(--rdp-accent-color); /* The background color of the date when at the end of the selected range. */ + + --rdp-week_number-border-radius: 100%; /* The border radius of the week number. */ + --rdp-week_number-border: 2px solid transparent; /* The border of the week number. */ + + --rdp-week_number-height: var(--rdp-day-height); /* The height of the week number cells. */ + --rdp-week_number-opacity: 0.75; /* The opacity of the week number. */ + --rdp-week_number-width: var(--rdp-day-width); /* The width of the week number cells. */ + --rdp-weeknumber-text-align: center; /* The text alignment of the weekday cells. */ + + --rdp-weekday-opacity: 0.75; /* The opacity of the weekday. */ + --rdp-weekday-padding: 0.5rem 0rem; /* The padding of the weekday. */ + --rdp-weekday-text-align: center; /* The text alignment of the weekday cells. */ + + --rdp-gradient-direction: 90deg; } - .DayPicker-wrapper { - display: flex; - flex-wrap: wrap; + .rdp-root[dir="rtl"] { + --rdp-gradient-direction: -90deg; + } + + /* Root of the component. */ + .rdp-root { + position: relative; /* Required to position the navigation toolbar. */ + box-sizing: border-box; + } + + .rdp-root * { + box-sizing: border-box; + } + + .rdp-day { + width: var(--sizing500); + height: var(--sizing450); + text-align: center; + } + + .rdp-day_button { + background: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + color: inherit; justify-content: center; - position: relative; - user-select: none; - flex-direction: row; - padding: 1rem 0; + align-items: center; + display: flex; + min-width: var(--sizing500); + height: var(--sizing450); + border: var(--rdp-day_button-border); + border-radius: var(--rdp-day_button-border-radius); } - .DayPicker-Month { - display: table; - border-collapse: collapse; - border-spacing: 0; - user-select: none; - margin: 0 1rem; + .rdp-day_button:disabled { + cursor: revert; } - .DayPicker-NavBar { - position: absolute; - left: 0; - right: 0; + .rdp-day_button { + outline: none; } - .DayPicker-NavButton { - position: absolute; - width: 1.5rem; - height: 1.5rem; - background-repeat: no-repeat; - background-position: center; - background-size: contain; + .rdp-caption_label { + z-index: 1; + position: relative; + display: inline-flex; + align-items: center; + white-space: nowrap; + border: 0; + } + + .rdp-button_next, + .rdp-button_previous { + border: none; + background: none; + padding: 0; + margin: 0; cursor: pointer; + font: inherit; + color: inherit; + -moz-appearance: none; + -webkit-appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + appearance: none; + width: var(--rdp-nav_button-width); + height: var(--rdp-nav_button-height); } - .DayPicker-NavButton--prev { - left: 1rem; - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjI2cHgiIGhlaWdodD0iNTBweCIgdmlld0JveD0iMCAwIDI2IDUwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjIgKDEyMDQzKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5wcmV2PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9InByZXYiIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEzLjM5MzE5MywgMjUuMDAwMDAwKSBzY2FsZSgtMSwgMSkgdHJhbnNsYXRlKC0xMy4zOTMxOTMsIC0yNS4wMDAwMDApIHRyYW5zbGF0ZSgwLjg5MzE5MywgMC4wMDAwMDApIiBmaWxsPSIjNTY1QTVDIj4KICAgICAgICAgICAgPHBhdGggZD0iTTAsNDkuMTIzNzMzMSBMMCw0NS4zNjc0MzQ1IEwyMC4xMzE4NDU5LDI0LjcyMzA2MTIgTDAsNC4yMzEzODMxNCBMMCwwLjQ3NTA4NDQ1OSBMMjUsMjQuNzIzMDYxMiBMMCw0OS4xMjM3MzMxIEwwLDQ5LjEyMzczMzEgWiIgaWQ9InJpZ2h0IiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K"); + .rdp-button_next:disabled, + .rdp-button_previous:disabled { + cursor: revert; + opacity: var(--rdp-nav_button-disabled-opacity); } - .DayPicker-NavButton--next { - right: 1rem; - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjI2cHgiIGhlaWdodD0iNTBweCIgdmlld0JveD0iMCAwIDI2IDUwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjIgKDEyMDQzKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5uZXh0PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9Im5leHQiIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAuOTUxNDUxLCAwLjAwMDAwMCkiIGZpbGw9IiM1NjVBNUMiPgogICAgICAgICAgICA8cGF0aCBkPSJNMCw0OS4xMjM3MzMxIEwwLDQ1LjM2NzQzNDUgTDIwLjEzMTg0NTksMjQuNzIzMDYxMiBMMCw0LjIzMTM4MzE0IEwwLDAuNDc1MDg0NDU5IEwyNSwyNC43MjMwNjEyIEwwLDQ5LjEyMzczMzEgTDAsNDkuMTIzNzMzMSBaIiBpZD0icmlnaHQiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo="); + .rdp-chevron { + display: inline-block; + fill: var(--rdp-accent-color); } - .DayPicker-NavButton--interactionDisabled { - display: none; + .rdp-root[dir="rtl"] .rdp-nav .rdp-chevron { + transform: rotate(180deg); } - .DayPicker-Caption { - display: table-caption; - height: 1.5rem; - text-align: center; + .rdp-root[dir="rtl"] .rdp-nav .rdp-chevron { + transform: rotate(180deg); + transform-origin: 50%; } - .DayPicker-Weekdays { - display: table-header-group; + .rdp-dropdowns { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--rdp-dropdown-gap); + } + .rdp-dropdown { + z-index: 2; + /* Reset */ + opacity: 0; + appearance: none; + position: absolute; + inset-block-start: 0; + inset-block-end: 0; + inset-inline-start: 0; + width: 100%; + margin: 0; + padding: 0; + cursor: inherit; + border: none; + line-height: inherit; } - .DayPicker-WeekdaysRow { - display: table-row; + .rdp-dropdown_root { + position: relative; + display: inline-flex; + align-items: center; } - .DayPicker-Weekday { - display: table-cell; + .rdp-dropdown_root[data-disabled="true"] .rdp-chevron { + opacity: var(--rdp-disabled-opacity); + } - abbr { - text-decoration: none; - } + .rdp-month_caption { + display: flex; + align-content: center; + height: var(--rdp-nav-height); + font-weight: bold; + font-size: large; } - .DayPicker-Body { - display: table-row-group; + .rdp-months { + position: relative; + display: flex; + flex-wrap: wrap; + gap: var(--rdp-months-gap); + max-width: fit-content; } - .DayPicker-Week { - display: table-row; + .rdp-month_grid { + border-collapse: collapse; } - .DayPicker-Day { - display: table-cell; - padding: 0.5rem; - border: 1px solid #eaecec; - text-align: center; - cursor: pointer; - vertical-align: middle; + .rdp-nav { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + display: flex; + align-items: center; + height: var(--rdp-nav-height); + width: 100%; } - .DayPicker-WeekNumber { - display: table-cell; - padding: 0.5rem; - text-align: right; - vertical-align: middle; - min-width: 1rem; - font-size: 0.75em; - cursor: pointer; - color: #8b9898; + .rdp-weekday { + opacity: var(--rdp-weekday-opacity); + padding: var(--rdp-weekday-padding); + font-weight: 500; + font-size: smaller; + text-align: var(--rdp-weekday-text-align); + text-transform: var(--rdp-weekday-text-transform); } - .DayPicker--interactionDisabled .DayPicker-Day { - cursor: default; + .rdp-week_number { + opacity: var(--rdp-week_number-opacity); + font-weight: 400; + font-size: small; + height: var(--rdp-week_number-height); + width: var(--rdp-week_number-width); + border: var(--rdp-week_number-border); + border-radius: var(--rdp-week_number-border-radius); + text-align: var(--rdp-weeknumber-text-align); } - .DayPicker-Footer { - display: table-caption; - caption-side: bottom; - padding-top: 0.5rem; + /* DAY MODIFIERS */ + .rdp-today:not(.rdp-outside) { + color: var(--rdp-today-color); } - .DayPicker-TodayButton { - border: none; - background-image: none; - background-color: transparent; - box-shadow: none; - cursor: pointer; - color: #4a90e2; - font-size: 0.875em; + .rdp-selected { + font-weight: bold; + font-size: large; } - /* Default modifiers */ + .rdp-selected .rdp-day_button { + border: var(--rdp-selected-border); + } - .DayPicker-Day--today { - color: #d0021b; - font-weight: 500; + .rdp-outside { + opacity: var(--rdp-outside-opacity); } - .DayPicker-Day--disabled { - color: #dce0e0; - cursor: default; - background-color: #eff1f1; + .rdp-disabled { + opacity: var(--rdp-disabled-opacity); } - .DayPicker-Day--outside { - cursor: default; - color: #dce0e0; + .rdp-hidden { + visibility: hidden; + color: var(--rdp-range_start-color); } - /* Example modifiers */ + .rdp-range_start { + background: var(--rdp-range_start-background); + } - .DayPicker-Day--sunday { - background-color: #f7f8f8; + .rdp-range_start .rdp-day_button { + background-color: var(--rdp-range_start-date-background-color); + color: var(--rdp-range_start-color); } - .DayPicker-Day--sunday:not(.DayPicker-Day--today) { - color: #dce0e0; + .rdp-range_middle { + background-color: var(--rdp-range_middle-background-color); } - .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { - color: #fff; - background-color: #4a90e2; + .rdp-range_middle .rdp-day_button { + border-color: transparent; + border: unset; + border-radius: unset; + color: var(--rdp-range_middle-color); } - /* DayPickerInput */ + .rdp-range_end { + background: var(--rdp-range_end-background); + color: var(--rdp-range_end-color); + } - .DayPickerInput { - display: inline-block; + .rdp-range_end .rdp-day_button { + color: var(--rdp-range_start-color); + background-color: var(--rdp-range_end-date-background-color); } - .DayPickerInput-OverlayWrapper { - position: relative; + .rdp-range_start.rdp-range_end { + background: revert; } - .DayPickerInput-Overlay { - left: 0; - position: absolute; - background: white; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); + .rdp-focusable { + cursor: pointer; } `; const StyledDayPicker = styled.div` - ${addReactDayPickerStyles} - - position: absolute; - height: 346px; - width: 352px; - ${({ theme }) => css` - z-index: ${theme.zIndex.popover}; - ${!theme.focusRedesignOptOut && - ` - margin-top: var(--spacing050); - `} - `} + ${officialReactDayPickerStyling} - .DayPicker { + .rdp-root { z-index: 1000; top: calc(100% + 1px); left: 0; @@ -209,46 +322,40 @@ const StyledDayPicker = styled.div` border-radius: var(--borderRadius050); } - .DayPicker * { + .rdp-root * { box-sizing: border-box; } - .DayPicker:focus { + .rdp-root:focus { outline: none; } - .DayPicker abbr[title] { + .rdp-root abbr[title] { border: none; cursor: initial; } - .DayPicker-wrapper { + .rdp-months { padding: 0; - &:focus { - ${({ theme }) => - !theme.focusRedesignOptOut - ? addFocusStyling() - : /* istanbul ignore next */ oldFocusStyling} - border-radius: var(--borderRadius050); - } } - .DayPicker-Month { + .rdp-month { margin: 0 0 2px; } - .DayPicker-Body, - .DayPicker-Week { + .rdp-month_grid, + .rdp_weeks { width: 100%; + margin-top: 8px; } - .DayPicker-Caption { + .rdp-month_caption { color: var(--colorsActionMajorYin090); line-height: var(--sizing500); height: var(--sizing500); - //font: var(--typographyDatePickerCalendarMonthM); font assets to be updated part of FE-4975 font-size: 16px; font-weight: 800; + display: block; > div { margin: 0 auto; @@ -256,15 +363,24 @@ const StyledDayPicker = styled.div` } } - .DayPicker-Day { + .rdp-weekday { + border: medium; + width: var(--sizing500); + height: var(--sizing450); + font-weight: 800; + color: var(--colorsActionMinor500); + text-transform: uppercase; + font-size: 12px; + text-align: center; + } + + .rdp-day { min-width: var(--sizing500); height: var(--sizing450); padding: 0; - background-color: var(--colorsUtilityYang100); + background-color: transparent; cursor: pointer; border: none; - //font-family: var(--fontFamiliesDefault); font assets to be updated part of FE-4975 - //font: var(--typographyDatePickerCalendarDateM); font assets to be updated part of FE-4975 font-weight: var(--fontWeights500); font-size: var(--fontSizes100); line-height: var(--lineHeights500); @@ -275,17 +391,6 @@ const StyledDayPicker = styled.div` color: var(--colorsActionMajorYin090); } - ${({ theme }) => - ` - &:focus { - ${ - !theme.focusRedesignOptOut - ? addFocusStyling(true) - : /* istanbul ignore next */ oldFocusStyling - } - } - `} - + * { border-left: 1px; } @@ -295,42 +400,76 @@ const StyledDayPicker = styled.div` } } - .DayPicker-Day--today, - .DayPicker-Day--today.DayPicker-Day--outside { + .rdp-today, + .rdp-today.rdp-outside { color: var(--colorsActionMajorYin090); background-color: var(--colorsActionMinor200); } - .DayPicker-Day--outside { + .rdp-outside { color: var(--colorsActionMajorYin055); - background-color: var(--colorsUtilityYang100); + background-color: transparent; + } + + .rdp-today:not(.rdp-outside) { + font-weight: var(--fontWeights500); + border-radius: var(--borderRadius400); + color: inherit; } - .DayPicker-Day--disabled, - .DayPicker-Day--disabled:hover { + .rdp-disabled, + .rdp-disabled:hover { color: var(--colorsActionMajorYin030); background-color: var(--colorsUtilityYang100); cursor: default; - &.DayPicker-Day--today { + &.rdp-today { background-color: var(--colorsActionMinor200); } } - .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not( - .DayPicker-Day--outside - ) { + .rdp-selected:not(.rdp-disabled):not(.rdp-outside) { background-color: var(--colorsActionMajor500); color: var(--colorsUtilityYang100); border-radius: var(--borderRadius400); } - .DayPicker-Day--selected.DayPicker-Day--disabled:not( - .DayPicker-Day--outside - ) { + .rdp-selected.rdp-disabled:not(.rdp-outside) { background-color: var(--colorsActionMajor500); color: var(--colorsUtilityYang100); } + + .rdp-selected { + &:focus-visible { + outline: none; + } + } + + .rdp-selected .rdp-day_button { + border: none; + &:focus-visible { + outline: none; + } + } + + .rdp-focused:not(.rdp-disabled):not(.rdp-outside) { + ${({ theme }) => css` + ${!theme.focusRedesignOptOut + ? addFocusStyling(true) + : /* istanbul ignore next */ oldFocusStyling} + `} + border-radius: var(--borderRadius400); + } + + .rdp-day.rdp-selected { + ${({ theme }) => css` + &:focus { + ${!theme.focusRedesignOptOut + ? addFocusStyling(true) + : /* istanbul ignore next */ oldFocusStyling} + } + `} + } `; StyledDayPicker.defaultProps = { diff --git a/src/components/date/__internal__/navbar/navbar.component.tsx b/src/components/date/__internal__/navbar/navbar.component.tsx index 7d1b1c98db..9a7561a928 100644 --- a/src/components/date/__internal__/navbar/navbar.component.tsx +++ b/src/components/date/__internal__/navbar/navbar.component.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { NavProps } from "react-day-picker"; + import StyledButton from "./button.style"; import StyledNavbar from "./navbar.style"; import Icon from "../../../icon"; @@ -15,7 +17,7 @@ export const Navbar = ({ onPreviousClick, onNextClick, className, -}: NavbarProps) => { +}: NavProps) => { const locale = useLocale(); const { previousMonthButton, nextMonthButton } = locale.date.ariaLabels; @@ -35,14 +37,16 @@ export const Navbar = ({ onPreviousClick?.()} + onClick={(e) => { + onPreviousClick?.(e); + }} onKeyDown={handleKeyDown} > onNextClick?.()} + onClick={(e) => onNextClick?.(e)} onKeyDown={handleKeyDown} > diff --git a/src/components/date/__internal__/navbar/navbar.style.ts b/src/components/date/__internal__/navbar/navbar.style.ts index fe756c029e..e6bed9d55c 100644 --- a/src/components/date/__internal__/navbar/navbar.style.ts +++ b/src/components/date/__internal__/navbar/navbar.style.ts @@ -1,7 +1,7 @@ import styled from "styled-components"; const StyledNavbar = styled.div` - &.DayPicker-NavBar { + &.rdp-nav { display: flex; justify-content: space-between; padding: 0; diff --git a/src/components/date/__internal__/utils.test.ts b/src/components/date/__internal__/utils.test.ts index 468f4c88c4..20e8a12ee3 100644 --- a/src/components/date/__internal__/utils.test.ts +++ b/src/components/date/__internal__/utils.test.ts @@ -1,4 +1,7 @@ import MockDate from "mockdate"; + +import { de, enGB, enUS, hu } from "date-fns/locale"; + import { isDateValid, parseDate, @@ -9,6 +12,7 @@ import { parseISODate, getDisabledDays, checkISOFormatAndLength, + isValidLocaleDate, } from "./utils"; const formats = [ @@ -446,3 +450,61 @@ test.each(["foo", "2022-1-1", "2022-01-1", "22-01-01", " "])( expect(checkISOFormatAndLength(value)).toEqual(false); }, ); + +describe("isValidLocaleDate", () => { + describe("with UK date formats", () => { + test("should return true when valid UK date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("30/04/2022", enGB)).toEqual(true); + }); + + test("should return false when invalid UK date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("31/04/2022", enGB)).toEqual(false); // April 31st is invalid + }); + + test("should return false when non-date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("invalid-date", enGB)).toEqual(false); + }); + }); + + describe("with US date formats", () => { + test("should return true when valid US date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("04/30/2022", enUS)).toEqual(true); + }); + + test("should return false when invalid US date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("04/31/2022", enUS)).toEqual(false); // April 31st is invalid + }); + + test("should return false when non-date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("invalid-date", enUS)).toEqual(false); + }); + }); + + describe("with German date formats", () => { + test("should return true when valid German date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("30.04.2022", de)).toEqual(true); + }); + + test("should return false when invalid German date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("31.04.2022", de)).toEqual(false); // April 31st is invalid + }); + + test("should return false when non-date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("invalid-date", de)).toEqual(false); + }); + }); + + describe("with Hungarian date formats", () => { + test("should return true when valid Hungarian date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("2022. 04. 30.", hu)).toEqual(true); + }); + + test("should return false when invalid Hungarian date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("2022. 04. 31.", hu)).toEqual(false); // April 31st is invalid + }); + + test("should return false when non-date string passed to `isValidLocaleDate`", () => { + expect(isValidLocaleDate("invalid-date", hu)).toEqual(false); + }); + }); +}); diff --git a/src/components/date/__internal__/utils.ts b/src/components/date/__internal__/utils.ts index e7afeb121d..6ee019027b 100644 --- a/src/components/date/__internal__/utils.ts +++ b/src/components/date/__internal__/utils.ts @@ -1,9 +1,27 @@ -import { Modifier } from "react-day-picker"; -import { format, formatISO, isMatch, parse, parseISO } from "./date-fns-fp"; +import { Matcher } from "react-day-picker"; + +import { + format, + formatISO, + isMatch, + isValid, + parse, + parseISO, + parseWithOptions, +} from "./date-fns-fp"; const DATE_STRING_LENGTH = 10; const THRESHOLD_FOR_ADDITIONAL_YEARS = 69; +export function isValidLocaleDate(date: string, locale: Locale) { + const dateFormat = "P"; + const parseDateWithLocale = parseWithOptions({ locale }); + const parsedDate = parseDateWithLocale(new Date(), dateFormat, date); + const isValidDate = isValid(parsedDate); + + return isValidDate; +} + export function parseDate(formatString?: string, valueString?: string) { if (!valueString || !formatString) return undefined; @@ -220,7 +238,7 @@ export function checkISOFormatAndLength(value: string) { export function getDisabledDays( minDate = "", maxDate = "", -): Modifier | Modifier[] { +): NonNullable | NonNullable | undefined { const days = []; if (!minDate && !maxDate) { diff --git a/src/components/date/__internal__/weekday/weekday.style.ts b/src/components/date/__internal__/weekday/weekday.style.ts index c65857ab2b..a3ccde62ee 100644 --- a/src/components/date/__internal__/weekday/weekday.style.ts +++ b/src/components/date/__internal__/weekday/weekday.style.ts @@ -1,8 +1,10 @@ import styled from "styled-components"; -const StyledWeekday = styled.div` +import StyledAbbr from "./abbr.style"; + +const StyledWeekday = styled.th` &, - &.DayPicker-Weekday { + & ${StyledAbbr} { border: none; height: var(--sizing500); min-width: var(--sizing500); diff --git a/src/components/date/__internal__/weekday/weekday.test.tsx b/src/components/date/__internal__/weekday/weekday.test.tsx index c6a546a294..e31d9bae13 100644 --- a/src/components/date/__internal__/weekday/weekday.test.tsx +++ b/src/components/date/__internal__/weekday/weekday.test.tsx @@ -3,8 +3,22 @@ import { screen, render } from "@testing-library/react"; import Weekday from "./weekday.component"; +const Component = (props: { + children: React.ReactNode; + className?: string; + title?: string; +}) => ( + + + + Foo + + +
+); + test("should render the passed `title` as the attribute on the `abbr` element", () => { - render(Foo); + render(Foo); const abbr = screen.getByTitle("title"); expect(abbr).toBeInTheDocument(); @@ -12,7 +26,7 @@ test("should render the passed `title` as the attribute on the `abbr` element", }); test("should render the passed `className` on the `div` element", () => { - render(Foo); + render(Foo); const weekday = screen.getByRole("columnheader", { name: "Foo" }); expect(weekday).toHaveClass("custom-class"); diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index 7205cfaaa3..85d8755363 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -17,6 +17,7 @@ import { parseISODate, checkISOFormatAndLength, getSeparator, + isValidLocaleDate, } from "./__internal__/utils"; import useLocale from "../../hooks/__internal__/useLocale"; import Events from "../../__internal__/utils/helpers/events"; @@ -125,7 +126,7 @@ export const DateInput = React.forwardRef( onClick, onFocus, onKeyDown, - pickerProps = {}, + pickerProps, readOnly, size = "medium", tooltipPosition, @@ -155,11 +156,16 @@ export const DateInput = React.forwardRef( ); const { inputRefMap, setInputRefMap } = useContext(DateRangeContext); const [open, setOpen] = useState(false); - const [selectedDays, setSelectedDays] = useState( - checkISOFormatAndLength(value) + const [selectedDays, setSelectedDays] = useState(() => { + const isValidDate = isValidLocaleDate(value, dateFnsLocale()); + if (!isValidDate) { + return undefined; + } + + return checkISOFormatAndLength(value) ? parseISODate(value) - : parseDate(format, value), - ); + : parseDate(format, value); + }); const isInitialValue = useRef(true); const pickerTabGuardId = useRef(guid()); @@ -220,7 +226,7 @@ export const DateInput = React.forwardRef( return function cleanup() { document.removeEventListener("mousedown", handleClick); }; - }, [open]); + }, [open, onPickerClose]); const handleChange = (ev: React.ChangeEvent) => { isInitialValue.current = false; diff --git a/src/components/date/date.mdx b/src/components/date/date.mdx index cb36dd38b8..3c7952c1b7 100644 --- a/src/components/date/date.mdx +++ b/src/components/date/date.mdx @@ -64,6 +64,12 @@ be used to set the input value like in the example below, although the component +### With disabled dates + +You can configure the available dates by passing a `minDate` and `maxDate` prop to the component. + + + ### With labelInline **Note:** The `labelInline` prop is not supported if the `validationRedesignOptIn` flag on the `CarbonProvider` is true. diff --git a/src/components/date/date.pw.tsx b/src/components/date/date.pw.tsx index df29c409af..cb329fdda5 100644 --- a/src/components/date/date.pw.tsx +++ b/src/components/date/date.pw.tsx @@ -1,6 +1,7 @@ import React from "react"; import { expect, test } from "@playwright/experimental-ct-react17"; import dayjs from "dayjs"; +import advancedFormat from "dayjs/plugin/advancedFormat"; import { DateInputCustom, DateInputValidationNewDesign, @@ -33,9 +34,11 @@ import { import { HooksConfig } from "../../../playwright"; import { alertDialogPreview } from "../../../playwright/components/dialog"; +dayjs.extend(advancedFormat); + const testData = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS]; -const DAY_PICKER_PREFIX = "DayPicker-Day--"; -const TODAY = dayjs().format("ddd D MMM YYYY"); +const DAY_PICKER_PREFIX = "rdp-"; +const TODAY = dayjs().format("dddd, MMMM Do, YYYY"); const DATE_INPUT = dayjs("2022-05-01").format("DD/MM/YYYY"); const TODAY_DATE_INPUT = dayjs().format("DD/MM/YYYY"); const NEXT_MONTH = dayjs("2022-05-01").add(1, "months").format("MMMM YYYY"); @@ -44,8 +47,8 @@ const PREVIOUS_MONTH = dayjs("2022-05-01") .subtract(1, "months") .format("MMMM YYYY"); const MIN_DATE = "04/04/2030"; -const DAY_BEFORE_MIN_DATE = "Wed 3 Apr 2030"; -const DAY_AFTER_MAX_DATE = "Fri 5 Apr 2030"; +const DAY_BEFORE_MIN_DATE = "Wednesday, April 3rd, 2030"; +const DAY_AFTER_MAX_DATE = "Friday, April 5th, 2030"; const DDMMYYY_DATE_TO_ENTER = "27,05,2022"; const MMDDYYYY_DATE_TO_ENTER = "05,27,2022"; const YYYYMMDD_DATE_TO_ENTER = "2022,05,27"; @@ -104,11 +107,10 @@ test.describe("Functionality tests", () => { const input = getDataElementByValue(page, "input"); await input.fill(MIN_DATE); - const dayPicker = page - .getByRole("row") - .locator(`div[aria-label="${DAY_BEFORE_MIN_DATE}"]`); - await expect(dayPicker).toHaveAttribute("aria-disabled", "true"); - await expect(dayPicker).toHaveAttribute("aria-selected", "false"); + const dayPicker = page.locator( + `button[aria-label="${DAY_BEFORE_MIN_DATE}"]`, + ); + await expect(dayPicker).toHaveAttribute("disabled", ""); }); test(`should check the maxDate prop`, async ({ mount, page }) => { @@ -117,11 +119,10 @@ test.describe("Functionality tests", () => { const input = getDataElementByValue(page, "input"); await input.fill(MIN_DATE); - const dayPicker = page - .getByRole("row") - .locator(`div[aria-label="${DAY_AFTER_MAX_DATE}"]`); - await expect(dayPicker).toHaveAttribute("aria-disabled", "true"); - await expect(dayPicker).toHaveAttribute("aria-selected", "false"); + const dayPicker = page.locator( + `button[aria-label="${DAY_AFTER_MAX_DATE}"]`, + ); + await expect(dayPicker).toHaveAttribute("disabled", ""); }); test(`should check the date is set to today's day`, async ({ @@ -130,15 +131,17 @@ test.describe("Functionality tests", () => { }) => { await mount(); - const dayClass = `DayPicker-Day ${DAY_PICKER_PREFIX}selected ${DAY_PICKER_PREFIX}today`; + const dayClass = `rdp-day rdp-today`; const input = getDataElementByValue(page, "input"); await input.fill(TODAY_DATE_INPUT); - const dayPicker = page - .getByRole("row") - .locator(`div[aria-label="${TODAY}"]`); - await expect(dayPicker).toHaveAttribute("aria-label", TODAY); - await expect(dayPicker).toHaveClass(dayClass); + const todayButton = page.getByRole("button", { name: `Today, ${TODAY}` }); + const todayCell = page.getByRole("gridcell").filter({ + has: todayButton, + }); + + await expect(todayButton).toBeVisible(); + await expect(todayCell).toHaveClass(dayClass); }); test(`should open dayPicker after click on input`, async ({ @@ -202,7 +205,7 @@ test.describe("Functionality tests", () => { const inputParent = getDataElementByValue(page, "input").locator(".."); await inputParent.click(); - const wrapperParent = dayPickerWrapper(page).locator("..").locator(".."); + const wrapperParent = dayPickerWrapper(page).locator(".."); await expect(wrapperParent).toHaveAttribute( "data-floating-placement", `${position}-start`, @@ -293,7 +296,7 @@ test.describe("Functionality tests", () => { ); await expect(arrowRight).toBeFocused(); await page.keyboard.press("Tab"); - const dayPicker = page.locator(`.${DAY_PICKER_PREFIX}selected`); + const dayPicker = page.locator(`.rdp-selected`).locator("button"); await expect(dayPicker).toBeFocused(); }); @@ -316,7 +319,9 @@ test.describe("Functionality tests", () => { ); await expect(arrowRight).toBeFocused(); await page.keyboard.press("Tab"); - const dayPicker = page.locator(`.${DAY_PICKER_PREFIX}selected`); + const dayPicker = page + .locator(`.${DAY_PICKER_PREFIX}selected`) + .locator("button"); await expect(dayPicker).toBeFocused(); await page.keyboard.press("Tab"); const wrapper = dayPickerWrapper(page); @@ -336,7 +341,9 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); - const dayPicker = page.locator(`.${DAY_PICKER_PREFIX}today`); + const dayPicker = page + .locator(`.${DAY_PICKER_PREFIX}today`) + .locator("button"); await expect(dayPicker).toBeFocused(); await page.keyboard.press("Tab"); const wrapper = dayPickerWrapper(page); @@ -347,7 +354,7 @@ test.describe("Functionality tests", () => { mount, page, }) => { - await mount(); + await mount(); await page.focus("body"); await page.keyboard.press("Tab"); @@ -356,62 +363,54 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); await page.keyboard.press(arrowKeys[3]); const focusedElement1 = page - .getByRole("row") - .nth(2) - .locator("div") - .filter({ hasText: "8" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "21" }); await expect(focusedElement1).toBeFocused(); await page.keyboard.press(arrowKeys[3]); const focusedElement2 = page - .getByRole("row") - .nth(3) - .locator("div") - .filter({ hasText: "15" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "28" }); await expect(focusedElement2).toBeFocused(); await page.keyboard.press(arrowKeys[1]); const focusedElement3 = page - .getByRole("row") - .nth(3) - .locator("div") - .filter({ hasText: "14" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "27" }); await expect(focusedElement3).toBeFocused(); await page.keyboard.press(arrowKeys[1]); const focusedElement4 = page - .getByRole("row") - .nth(3) - .locator("div") - .filter({ hasText: "13" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "26" }); await expect(focusedElement4).toBeFocused(); await page.keyboard.press(arrowKeys[0]); const focusedElement5 = page - .getByRole("row") - .nth(3) - .locator("div") - .filter({ hasText: "14" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "27" }); await expect(focusedElement5).toBeFocused(); await page.keyboard.press(arrowKeys[0]); const focusedElement6 = page - .getByRole("row") - .nth(3) - .locator("div") - .filter({ hasText: "15" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "28" }); await expect(focusedElement6).toBeFocused(); await page.keyboard.press(arrowKeys[2]); const focusedElement7 = page - .getByRole("row") - .nth(2) - .locator("div") - .filter({ hasText: "8" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "21" }); await expect(focusedElement7).toBeFocused(); await page.keyboard.press(arrowKeys[2]); const focusedElement8 = page - .getByRole("row") - .nth(1) - .locator("div") - .filter({ hasText: "1" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "14" }); await expect(focusedElement8).toBeFocused(); }); @@ -419,7 +418,7 @@ test.describe("Functionality tests", () => { mount, page, }) => { - await mount(); + await mount(); await page.focus("body"); await page.keyboard.press("Tab"); @@ -428,10 +427,9 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); await page.keyboard.press(arrowKeys[1]); const focusedElement = page - .getByRole("row") - .nth(5) - .locator("div") - .filter({ hasText: "30" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "13" }); await expect(focusedElement).toBeFocused(); const pickerHeading = dayPickerHeading(page); await expect(pickerHeading).toHaveText(PREVIOUS_MONTH); @@ -460,16 +458,14 @@ test.describe("Functionality tests", () => { await page.keyboard.press(arrowKeys[2]); if (day === "1") { const focusedElement = page - .getByRole("row") - .nth(4) - .locator("div") + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") .filter({ hasText: result }); await expect(focusedElement).toBeFocused(); } else { const focusedElement = page - .getByRole("row") - .nth(5) - .locator("div") + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") .filter({ hasText: result }); await expect(focusedElement).toBeFocused(); } @@ -501,16 +497,14 @@ test.describe("Functionality tests", () => { await page.keyboard.press(arrowKeys[3]); if (day === "30" || day === "31") { const focusedElement = page - .getByRole("row") - .nth(2) - .locator("div") + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") .filter({ hasText: result }); await expect(focusedElement).toBeFocused(); } else { const focusedElement = page - .getByRole("row") - .nth(1) - .locator("div") + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") .filter({ hasText: result }) .filter({ hasNotText: "30" }) .filter({ hasNotText: "31" }); @@ -526,7 +520,7 @@ test.describe("Functionality tests", () => { mount, page, }) => { - await mount(); + await mount(); await page.focus("body"); await page.keyboard.press("Tab"); @@ -535,14 +529,13 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); await page.keyboard.press(arrowKeys[1]); const focusedElement = page - .getByRole("row") - .nth(5) - .locator("div") - .filter({ hasText: "30" }); + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") + .filter({ hasText: "13" }); await expect(focusedElement).toBeFocused(); await page.keyboard.press(key); await expect(getDataElementByValue(page, "input")).toHaveValue( - "30/04/2022", + "13/04/2022", ); }); }); @@ -606,9 +599,8 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); await page.keyboard.press(arrowKeys[0]); const focusedElement = page - .getByRole("row") - .nth(1) - .locator("div") + .locator(`.${DAY_PICKER_PREFIX}focused`) + .locator("button") .filter({ hasText: "1" }) .filter({ hasNotText: "31" }); await expect(focusedElement).toBeFocused(); @@ -752,17 +744,17 @@ test.describe("Functionality tests", () => { const input = getDataElementByValue(page, "input"); await input.click(); - const months = page.locator("div[class=DayPicker-Month]"); + const months = page.locator("div[class=rdp-month]"); await expect(months).toHaveCount(2); const pickerHeading1 = page - .locator(".DayPicker-Caption") - .locator("div") + .locator(".rdp-month_caption") + .locator("span") .nth(0); await expect(pickerHeading1).toBeVisible(); await expect(pickerHeading1).toHaveText(ACTUAL_MONTH); const pickerHeading2 = page - .locator(".DayPicker-Caption") - .locator("div") + .locator(".rdp-month_caption") + .locator("span") .nth(1); await expect(pickerHeading2).toBeVisible(); await expect(pickerHeading2).toHaveText(NEXT_MONTH); @@ -836,9 +828,11 @@ test.describe("Functionality tests", () => { const input = getDataElementByValue(page, "input"); await input.click(); await expect(input).toHaveCSS("border-radius", "4px"); - const dayPicker1 = page.getByLabel("Sun 1 May 2022"); + const dayPicker1 = page + .getByLabel("Sunday, May 1st, 2022, selected") + .locator(".."); await expect(dayPicker1).toHaveCSS("border-radius", "32px"); - const dayPicker2 = page.getByLabel("Mon 2 May 2022"); + const dayPicker2 = page.getByLabel("Monday, May 2nd, 2022").locator(".."); await expect(dayPicker2).toHaveCSS("border-radius", "32px"); const dayPickerNavButton1 = page.getByLabel("Previous month"); await expect(dayPickerNavButton1).toHaveCSS("border-radius", "4px"); @@ -858,18 +852,37 @@ test.describe("Functionality tests", () => { await page.keyboard.press("Tab"); const inputParent = getDataElementByValue(page, "input").locator(".."); await checkGoldenOutline(inputParent); - const dayPicker1 = page.getByLabel("Sun 1 May 2022"); - await dayPicker1.focus(); - await checkGoldenOutline(dayPicker1); - const dayPicker2 = page.getByLabel("Mon 2 May 2022"); - await dayPicker2.focus(); - await checkGoldenOutline(dayPicker2); + await page.keyboard.press("Tab"); const dayPickerNavButton1 = page.getByLabel("Previous month"); await dayPickerNavButton1.focus(); - await checkGoldenOutline(dayPickerNavButton1); + await expect(dayPickerNavButton1).toHaveCSS( + "outline", + "rgb(255, 188, 25) solid 3px", + ); + await page.keyboard.press("Tab"); const dayPickerNavButton2 = page.getByLabel("Next month"); await dayPickerNavButton2.focus(); - await checkGoldenOutline(dayPickerNavButton2); + await expect(dayPickerNavButton2).toHaveCSS( + "outline", + "rgb(255, 188, 25) solid 3px", + ); + await page.keyboard.press("Tab"); + const dayPicker1 = page + .getByLabel("Sunday, May 1st, 2022, selected") + .locator(".."); + await dayPicker1.focus(); + await expect(dayPicker1).toHaveCSS( + "outline", + "rgb(255, 188, 25) solid 3px", + ); + + await page.keyboard.press("ArrowRight"); + const dayPicker2 = page.getByLabel("Monday, May 2nd, 2022").locator(".."); + await dayPicker2.focus(); + await expect(dayPicker2).toHaveCSS( + "outline", + "rgb(255, 188, 25) solid 3px", + ); }); test(`should have the expected styling when opt out flag is false`, async ({ @@ -880,8 +893,6 @@ test.describe("Functionality tests", () => { await page.focus("body"); await page.keyboard.press("Tab"); - const wrapperParent = dayPickerWrapper(page).locator("..").locator(".."); - await expect(wrapperParent).toHaveCSS("margin-top", "4px"); const inputParent = getDataElementByValue(page, "input").locator(".."); await expect(inputParent).toHaveCSS( "box-shadow", @@ -891,20 +902,8 @@ test.describe("Functionality tests", () => { "outline", "rgba(0, 0, 0, 0) solid 3px", ); - const dayPicker1 = page.getByLabel("Sun 1 May 2022"); - await dayPicker1.focus(); - await expect(dayPicker1).toHaveCSS( - "box-shadow", - "rgba(0, 0, 0, 0.9) 0px 0px 0px 3px inset, rgb(255, 188, 25) 0px 0px 0px 6px inset", - ); - await expect(dayPicker1).toHaveCSS("outline", "rgba(0, 0, 0, 0) solid 3px"); - const dayPicker2 = page.getByLabel("Mon 2 May 2022"); - await dayPicker2.focus(); - await expect(dayPicker2).toHaveCSS( - "box-shadow", - "rgba(0, 0, 0, 0.9) 0px 0px 0px 3px inset, rgb(255, 188, 25) 0px 0px 0px 6px inset", - ); - await expect(dayPicker2).toHaveCSS("outline", "rgba(0, 0, 0, 0) solid 3px"); + + await page.keyboard.press("Tab"); const dayPickerNavButton1 = page.getByLabel("Previous month"); await dayPickerNavButton1.focus(); await expect(dayPickerNavButton1).toHaveCSS( @@ -915,6 +914,8 @@ test.describe("Functionality tests", () => { "outline", "rgba(0, 0, 0, 0) solid 3px", ); + + await page.keyboard.press("Tab"); const dayPickerNavButton2 = page.getByLabel("Next month"); await dayPickerNavButton2.focus(); await expect(dayPickerNavButton2).toHaveCSS( @@ -925,6 +926,24 @@ test.describe("Functionality tests", () => { "outline", "rgba(0, 0, 0, 0) solid 3px", ); + + await page.keyboard.press("Tab"); + const dayPicker1 = page.getByLabel("Sunday, May 1st, 2022").locator(".."); + await dayPicker1.focus(); + await expect(dayPicker1).toHaveCSS( + "box-shadow", + "rgba(0, 0, 0, 0.9) 0px 0px 0px 3px inset, rgb(255, 188, 25) 0px 0px 0px 6px inset", + ); + await expect(dayPicker1).toHaveCSS("outline", "rgba(0, 0, 0, 0) solid 3px"); + + await page.keyboard.press("ArrowRight"); + const dayPicker2 = page.getByLabel("Monday, May 2nd, 2022").locator(".."); + await dayPicker2.focus(); + await expect(dayPicker2).toHaveCSS( + "box-shadow", + "rgba(0, 0, 0, 0.9) 0px 0px 0px 3px inset, rgb(255, 188, 25) 0px 0px 0px 6px inset", + ); + await expect(dayPicker2).toHaveCSS("outline", "rgba(0, 0, 0, 0) solid 3px"); }); (["top", "bottom", "left", "right"] as const).forEach((position) => { diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index 9df69d32cc..e0e5bf6217 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -124,6 +124,26 @@ export const Empty: Story = () => { }; Empty.storyName = "Empty"; +export const DisabledDates: Story = () => { + const [state, setState] = useState("04/04/2019"); + const setValue = (ev: DateChangeEvent) => { + setState(ev.target.value.formattedValue); + }; + return ( + console.log("blur")} + /> + ); +}; +DisabledDates.storyName = "Disabled Dates"; +DisabledDates.parameters = { chromatic: { disableSnapshot: true } }; + export const WithLabelInline: Story = () => { const [state, setState] = useState("01/10/2016"); const setValue = (ev: DateChangeEvent) => { diff --git a/src/components/date/date.test.tsx b/src/components/date/date.test.tsx index 2ee4aa1db3..dbb9592072 100644 --- a/src/components/date/date.test.tsx +++ b/src/components/date/date.test.tsx @@ -68,7 +68,7 @@ const MockComponent = ({ ); }; -// temporatrily running timers on every spec as we have issues +// temporarily running timers on every spec as we have issues // around how slow the tests that open the calendar are. // FE-6724 raised to investigate and implement a better solution beforeAll(() => { @@ -479,23 +479,21 @@ test("should not close the picker or call the `onChange` and `onBlur` callbacks test("should not close the picker or call the `onChange` and `onBlur` callbacks when the user clicks on a disabled day", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const onChange = jest.fn(); - const onBlur = jest.fn(); render( , ); const input = screen.getByRole("textbox"); await user.click(input); jest.advanceTimersByTime(10); - await user.click(screen.getByRole("gridcell", { name: "Wed 3 Apr 2019" })); + await user.click(screen.getByLabelText("Wednesday, April 3rd, 2019")); expect(screen.queryByRole("grid")).toBeVisible(); expect(onChange).not.toHaveBeenCalled(); - expect(onBlur).not.toHaveBeenCalled(); }); test("should close the open picker when a user presses the 'Escape' key", async () => { @@ -609,7 +607,7 @@ test("should focus the next button and then the selected day element when the us expect(screen.getByRole("button", { name: "Next month" })).toHaveFocus(); await user.tab(); expect( - screen.getByRole("gridcell", { name: "Thu 4 Apr 2019" }), + screen.getByLabelText("Thursday, April 4th, 2019", { exact: false }), ).toHaveFocus(); await user.tab(); expect(screen.queryByRole("grid")).not.toBeInTheDocument(); @@ -621,10 +619,12 @@ test("should close the picker, update the value and refocus the input element wh const input = screen.getByRole("textbox"); await user.click(input); jest.advanceTimersByTime(10); - await user.click(screen.getByRole("gridcell", { name: "Thu 11 Apr 2019" })); + await user.click(screen.getByLabelText("Thursday, April 11th, 2019")); expect(screen.queryByRole("grid")).not.toBeInTheDocument(); - expect(input).toHaveFocus(); + await waitFor(() => { + expect(input).toHaveFocus(); + }); expect(input).toHaveValue("11/04/2019"); }); @@ -1668,6 +1668,6 @@ test("should select the correct date when the locale is overridden and a date is await user.type(input, "05/04"); jest.advanceTimersByTime(10); - const grid = screen.getByRole("grid").childNodes[0].textContent; + const grid = screen.getByRole("status").textContent; expect(grid).toEqual("April 2019"); }); From daeb37a4fa5a7c9adcaacc004f165d551c104a1c Mon Sep 17 00:00:00 2001 From: "tom.davies" Date: Thu, 5 Dec 2024 15:28:46 +0000 Subject: [PATCH 09/21] feat(multi-select): add onListScrollBottom adds the onListScrollBottom callback, to be called when a user scrolls to the bottom of the select list fix #6752 --- .../multi-select/components.test-pw.tsx | 79 ++++++++++++ .../multi-select-test.stories.tsx | 80 ++++++++++++ .../multi-select/multi-select.component.tsx | 4 + .../select/multi-select/multi-select.mdx | 7 + .../select/multi-select/multi-select.pw.tsx | 122 ++++++++++++++++++ .../multi-select/multi-select.stories.tsx | 90 +++++++++++++ .../select/multi-select/multi-select.test.tsx | 19 +++ 7 files changed, 401 insertions(+) diff --git a/src/components/select/multi-select/components.test-pw.tsx b/src/components/select/multi-select/components.test-pw.tsx index b161b6fc97..04a3cb0f55 100644 --- a/src/components/select/multi-select/components.test-pw.tsx +++ b/src/components/select/multi-select/components.test-pw.tsx @@ -194,6 +194,85 @@ export const MultiSelectLazyLoadTwiceComponent = ( ); }; +export const MultiSelectWithInfiniteScrollComponent = ( + props: Partial, +) => { + const preventLoading = useRef(false); + const preventLazyLoading = useRef(false); + const lazyLoadingCounter = useRef(0); + const [value, setValue] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const asyncList = [ +