diff --git a/CHANGELOG.md b/CHANGELOG.md index 7318f48e5d..5159afa8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,50 @@ +### [123.4.1](https://github.com/Sage/carbon/compare/v123.4.0...v123.4.1) (2023-11-03) + + +### Bug Fixes + +* **loader-bar:** loaderBar height fix ([233782e](https://github.com/Sage/carbon/commit/233782ef1a7290e4582ac44a20fa425584570723)), closes [#5999](https://github.com/Sage/carbon/issues/5999) + +## [123.4.0](https://github.com/Sage/carbon/compare/v123.3.0...v123.4.0) (2023-11-02) + + +### Features + +* **date:** add support for keyboard navigation in picker ([11d1853](https://github.com/Sage/carbon/commit/11d185306a6cf30e67e1efcf31c7985366594b78)), closes [#6324](https://github.com/Sage/carbon/issues/6324) [#3969](https://github.com/Sage/carbon/issues/3969) + + +### Bug Fixes + +* **date:** add aria labels to picker navigation buttons and fix other accessibility issues ([b5eb150](https://github.com/Sage/carbon/commit/b5eb150bcc5760f548db60610f70c5ccd6a7db18)), closes [#5804](https://github.com/Sage/carbon/issues/5804) + +## [123.3.0](https://github.com/Sage/carbon/compare/v123.2.2...v123.3.0) (2023-11-02) + + +### Features + +* **message:** remove role of status from component ([f09ef5e](https://github.com/Sage/carbon/commit/f09ef5ecf76ae1551a6e01dac1c45be56583df2b)), closes [#6013](https://github.com/Sage/carbon/issues/6013) + +### [123.2.2](https://github.com/Sage/carbon/compare/v123.2.1...v123.2.2) (2023-11-02) + + +### Bug Fixes + +* **action-popover:** fix issue with stories not rendering in storybook ([437ee03](https://github.com/Sage/carbon/commit/437ee03386d82c9d32799efce05ea009cc7feb6f)) + +### [123.2.1](https://github.com/Sage/carbon/compare/v123.2.0...v123.2.1) (2023-11-01) + + +### Bug Fixes + +* **navigation-bar:** stop ResizeObserver errors being thrown on render ([d80db16](https://github.com/Sage/carbon/commit/d80db16272daf24579c0619ec68bb119e19bcd78)), closes [#6259](https://github.com/Sage/carbon/issues/6259) + +## [123.2.0](https://github.com/Sage/carbon/compare/v123.1.0...v123.2.0) (2023-10-30) + + +### Features + +* **confirm:** make data tag props available on cancel and confirm buttons ([2c69101](https://github.com/Sage/carbon/commit/2c691011688c5186a7d20f8af7e6acd768e5a0e8)), closes [#6374](https://github.com/Sage/carbon/issues/6374) + ## [123.1.0](https://github.com/Sage/carbon/compare/v123.0.1...v123.1.0) (2023-10-27) diff --git a/cypress/components/date/date.cy.tsx b/cypress/components/date/date.cy.tsx index 02ffe3615d..9c28bc31dc 100644 --- a/cypress/components/date/date.cy.tsx +++ b/cypress/components/date/date.cy.tsx @@ -42,7 +42,7 @@ import { } from "../../locators/date-input"; import { getDataElementByValue, fieldHelpPreview } from "../../locators"; -import { keyCode } from "../../support/helper"; +import { KeyIds, keyCode } from "../../support/helper"; import { verifyRequiredAsteriskForLabel, assertCssValueIsApproximately, @@ -75,7 +75,12 @@ const DDMMYYY_DATE_TO_ENTER_SHORT = "1,7,22"; const MMDDYYYY_DATE_TO_ENTER_SHORT = "7,1,22"; const YYYYMMDD_DATE_TO_ENTER_SHORT = "22,7,1"; const DATE_TO_VERIFY = "2022-05-12"; -const keysToTrigger = ["rightarrow", "leftarrow"] as const; +const arrowKeys = [ + "rightarrow", + "leftarrow", + "uparrow", + "downarrow", +] as KeyIds[]; context("Test for DateInput component", () => { describe("check functionality for DateInput component", () => { @@ -222,26 +227,239 @@ context("Test for DateInput component", () => { } ); + it.each(arrowKeys)( + "should not change the displayed month when %s is pressed and next button is focused", + (key) => { + CypressMountWithProviders(); + dateInputParent().click(); + getDataElementByValue("chevron_right").parent().focus(); + getDataElementByValue("chevron_right").trigger("keydown", keyCode(key)); + dayPickerHeading().should("have.text", "May 2022"); + } + ); + + it.each(arrowKeys)( + "should not change the displayed month when %s is pressed and previous button is focused", + (key) => { + CypressMountWithProviders(); + dateInputParent().click(); + getDataElementByValue("chevron_left").parent().focus(); + getDataElementByValue("chevron_left").trigger("keydown", keyCode(key)); + dayPickerHeading().should("have.text", "May 2022"); + } + ); + + it("should allow a user to tab into the picker and through its controls", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--selected").should("be.focused"); + }); + + it("should close the picker and focus the next element in the DOM when focus is on a day element and tab pressed", () => { + CypressMountWithProviders( + <> + + + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--selected").should("be.focused"); + cy.focused().tab(); + dayPickerWrapper().should("not.exist"); + cy.get('[data-element="foo-button"]').should("be.focused"); + }); + + it("should focus today's date if no day selected when tabbing to day elements", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--today").should("be.focused"); + cy.focused().tab(); + dayPickerWrapper().should("not.exist"); + }); + + it("should navigate through the day elements using the arrow keys", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", "8"); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", "15"); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "14"); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "13"); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "14"); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "15"); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", "8"); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", "1"); + }); + + it("should navigate to the previous month when left arrow pressed on first day element of a month", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "30"); + dayPickerHeading().should("have.text", PREVIOUS_MONTH); + }); + + it.each([ + ["24", "1"], + ["25", "2"], + ["26", "3"], + ["27", "4"], + ["28", "5"], + ["29", "6"], + ["30", "7"], + ])( + "should navigate to day %s of previous month when up arrow pressed on day %s of first week of current month", + (result, input) => { + CypressMountWithProviders( + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", result); + dayPickerHeading().should("have.text", PREVIOUS_MONTH); + } + ); + + it.each([ + ["7", "31"], + ["6", "30"], + ["5", "29"], + ["4", "28"], + ["3", "27"], + ["2", "26"], + ["1", "25"], + ])( + "should navigate to day %s of next month when down arrow pressed on day %s of last week of current month", + (result, input) => { + CypressMountWithProviders( + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", result); + dayPickerHeading().should("have.text", NEXT_MONTH); + } + ); + + it.each(["Enter", "Space"] as KeyIds[])( + "should update the selected date when %s pressed on a day element", + (key) => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "30"); + cy.focused().trigger("keydown", keyCode(key)); + getDataElementByValue("input").should("have.value", "30/04/2022"); + } + ); + + it("should close the picker when escape is pressed and input focused", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + + cy.focused().trigger("keydown", keyCode("Esc")); + dayPickerWrapper().should("not.exist"); + }); + + it("should close the picker when escape is pressed and focus is within the picker and refocus the input", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("Esc")); + dayPickerWrapper().should("not.exist"); + getDataElementByValue("input").should("be.focused"); + }); + + it("should close the picker when shift + tab is pressed and focus is on the previous month button in the picker and refocus the input", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + cy.focused().tab(); + cy.focused().tab({ shift: true }); + dayPickerWrapper().should("not.exist"); + getDataElementByValue("input").should("be.focused"); + }); + + it("should navigate to the next month when right arrow pressed on last day element of a month", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "1"); + dayPickerHeading().should("have.text", NEXT_MONTH); + }); + it.each([ - ["chevron_right", "next", keysToTrigger[0]], - ["chevron_left", "previous", keysToTrigger[1]], + ["enter", "next", "chevron_right"], + ["space", "next", "chevron_right"], + ["enter", "previous", "chevron_left"], + ["space", "previous", "chevron_left"], ])( - "should trigger %s arrow in DayPicker to verify %s month is shown using %s keyboard key", - (arrow, month, key) => { + "should change the displayed month when %s is pressed and %s button is focused", + (key, month, arrow) => { CypressMountWithProviders(); - dateInput().clear().type(DATE_INPUT); + const keyToType = key === "space" ? " " : key; dateInputParent().click(); - - dayPickerWrapper().focus(); - getDataElementByValue(arrow).trigger("keydown", keyCode(key)); + getDataElementByValue(arrow).parent().focus(); + getDataElementByValue(arrow).type(`{${keyToType}}`); if (month === "next") { dayPickerHeading().should("have.text", NEXT_MONTH); } else if (month === "previous") { dayPickerHeading().should("have.text", PREVIOUS_MONTH); - } else { - throw new Error("Only Next or Previous month can be applied"); } } ); @@ -411,6 +629,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -478,6 +700,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -544,6 +770,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -662,6 +892,8 @@ context("Test for DateInput component", () => { dateInput().focus(); + dayPickerParent().should("have.css", "margin-top", "4px"); + dateInputParent() .should( "have.css", @@ -864,4 +1096,12 @@ context("Test for DateInput component", () => { cy.checkAccessibility(); }); + + it("should check accessibility when the picker is open", () => { + CypressMountWithProviders(); + + dateInputParent() + .click() + .then(() => cy.checkAccessibility()); + }); }); diff --git a/cypress/components/duelling-picklist/duelling-picklist.cy.tsx b/cypress/components/duelling-picklist/duelling-picklist.cy.tsx deleted file mode 100644 index 703cf51e38..0000000000 --- a/cypress/components/duelling-picklist/duelling-picklist.cy.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import React from "react"; -import { PicklistItemProps } from "../../../src/components/duelling-picklist/picklist-item/picklist-item.component"; -import { - DuellingPicklistComponent, - DuellingPicklistComponentPicklistItemProps, - DuellingPicklistComponentPicklistProps, -} from "../../../src/components/duelling-picklist/duelling-picklist-test.stories"; -import PicklistPlaceholder from "../../../src/components/duelling-picklist/picklist-placeholder/picklist-placeholder.component"; -import { - assignedPicklist, - unassignedPicklistItems, - duellingPicklistComponent, - picklistRightLabel, - picklistLeftLabel, - assignedPicklistItems, - unassignedPicklist, - addButton, - removeButton, - duellingSearchInput, - checkBox, - picklistGroup, -} from "../../locators/duelling-picklist/index"; -import { keyCode } from "../../support/helper"; -import CypressMountWithProviders from "../../support/component-helper/cypress-mount"; -import { getDataElementByValue, tooltipPreview } from "../../locators"; -import { CHARACTERS } from "../../support/component-helper/constants"; -import * as stories from "../../../src/components/duelling-picklist/duelling-picklist.stories"; -import { ICON } from "../../locators/locators"; - -const specialCharacters = [ - CHARACTERS.STANDARD, - CHARACTERS.DIACRITICS, - CHARACTERS.SPECIALCHARACTERS, -]; -const keyToTrigger = ["Space", "Enter"] as const; - -context("Testing Duelling-Picklist component", () => { - describe("should render Duelling-Picklist component", () => { - it("should verify unassigned picklist has 10 items", () => { - CypressMountWithProviders(); - - unassignedPicklistItems().should("have.length", 10); - picklistLeftLabel().should("have.text", "List 1 (10)"); - }); - - it("should verify assigned picklist has 0 items", () => { - CypressMountWithProviders(); - - assignedPicklistItems().should("have.length", "0"); - assignedPicklist().find("div").should("have.text", "Nothing to see here"); - picklistRightLabel().should("have.text", "List 2 (0)"); - }); - - it("should verify Duelling-Picklist is enabled by default", () => { - CypressMountWithProviders(); - - duellingPicklistComponent().should("not.have.attr", "disabled"); - }); - - it.each([ - [1, 9, 1], - [7, 3, 7], - ])( - "should verify when %s item(s) are assigned that unassigned picklist has %s items and assigned picklist has %s item(s)", - (items, leftItems) => { - CypressMountWithProviders(); - - for (let i = 0; i < items; i++) { - addButton(0).click(); - } - unassignedPicklistItems().should("have.length", leftItems); - picklistLeftLabel().should("have.text", `List 1 (${leftItems})`); - assignedPicklistItems().should("have.length", items); - picklistRightLabel().should("have.text", `List 2 (${items})`); - } - ); - - it("should verify assigned picklist has 10 items when all items are added", () => { - CypressMountWithProviders(); - - for (let i = 0; i < 10; i++) { - addButton(0).click(); - } - unassignedPicklistItems().should("have.length", 0); - unassignedPicklist() - .find("div") - .should("have.text", "Unassigned list empty"); - picklistLeftLabel().should("have.text", "List 1 (0)"); - assignedPicklistItems().should("have.length", 10); - picklistRightLabel().should("have.text", "List 2 (10)"); - }); - - it("should verify assigned picklist has 0 items when assigned item is removed", () => { - CypressMountWithProviders(); - - addButton(0).click(); - unassignedPicklistItems().should("have.length", 9); - assignedPicklistItems().should("have.length", 1); - removeButton(0).click(); - unassignedPicklistItems().should("have.length", 10); - assignedPicklistItems().should("have.length", 0); - }); - - it.each([...keyToTrigger])( - "should verify item is added to assigned picklist when %s key is pressed", - (pressed) => { - CypressMountWithProviders(); - - addButton(0).trigger("keydown", keyCode(pressed)); - unassignedPicklistItems().should("have.length", 9); - assignedPicklistItems().should("have.length", 1); - } - ); - - it.each([...keyToTrigger])( - "should verify item is removed from assigned picklist when %s key is pressed", - (pressed) => { - CypressMountWithProviders(); - - addButton(0).click(); - unassignedPicklistItems().should("have.length", 9); - assignedPicklistItems().should("have.length", 1); - removeButton(0).trigger("keydown", keyCode(pressed)); - unassignedPicklistItems().should("have.length", 10); - assignedPicklistItems().should("have.length", 0); - } - ); - - it.each([ - ["Content", 10], - ["Content 1", 2], - ["Content 10", 1], - ])( - "should verify when %s is enterted into search field that %s results are displayed", - (searchString, results) => { - CypressMountWithProviders(); - - duellingSearchInput().eq(0).type(searchString); - unassignedPicklistItems().should("have.length", results); - } - ); - - it("should verify leftControl prop in component generates search field appears above unassigned picklist", () => { - CypressMountWithProviders(); - - getDataElementByValue("picklist-left-control") - .children() - .should("have.attr", "data-component", "search"); - }); - - it("should verify rightControl prop in component generates search field appears above assigned picklist", () => { - CypressMountWithProviders(); - - getDataElementByValue("picklist-right-label") - .children() - .should("have.attr", "data-component", "search"); - }); - - it.each([ - ["disabled", true, "have.attr"], - ["enabled", false, "not.have.attr"], - ])( - "should verify Duelling-Picklist is %s when disabled prop is %s", - (state, bool, attribute) => { - CypressMountWithProviders( - - ); - - duellingPicklistComponent().should(attribute, "disabled"); - } - ); - - it("should verify unassigned picklist label is 'Left Label'", () => { - CypressMountWithProviders( - - ); - - picklistLeftLabel().should("have.text", "Left Label"); - }); - - it("should verify assigned picklist label is 'Right Label'", () => { - CypressMountWithProviders( - - ); - - picklistRightLabel().should("have.text", "Right Label"); - }); - }); - - describe("should render Duelling-Picklist component to test Picklist props", () => { - it.each([...specialCharacters])( - "should verify picklist placeholder is set to %s", - (chars) => { - CypressMountWithProviders( - } - /> - ); - - for (let i = 0; i < 10; i++) { - addButton(0).click(); - } - unassignedPicklist().find("div").should("have.text", chars); - } - ); - - it.each([ - ["locked", true, "have.attr", "rgb(242, 245, 246)"], - ["unlocked", false, "not.have.attr", "rgb(255, 255, 255)"], - ])( - "should verify picklist item is %s when locked prop is %s", - (state, bool, attribute, backColor) => { - CypressMountWithProviders( - - ); - - unassignedPicklistItems().should( - "have.css", - "background-color", - backColor - ); - unassignedPicklistItems() - .find(ICON) - .should(attribute, "data-element", state); - } - ); - - it("should verify picklist tooltip is 'Item Locked' when locked prop is true", () => { - CypressMountWithProviders( - - ); - - getDataElementByValue("picklist-item").eq(0).children().eq(1).realHover(); - tooltipPreview().should("have.text", "Item Locked"); - }); - }); - - describe("should render Duelling-Picklist with external searchbar and access checkbox", () => { - it.each([ - ["Content", 20], - ["Content 1", 11], - ["Content 10", 1], - ])( - "should verify picklist search field can be placed outside the Duelling-Picklist", - (searchString, results) => { - CypressMountWithProviders(); - - getDataElementByValue("input").type(searchString); - unassignedPicklistItems().should("have.length", results); - } - ); - - it("should verify Duelling-Picklist is disabled when access checkox is checked", () => { - CypressMountWithProviders(); - - checkBox().check(); - duellingPicklistComponent().should("have.attr", "disabled"); - }); - - it("should verify Duelling-Picklist is re-enabled when access checkbox is unchecked", () => { - CypressMountWithProviders(); - - checkBox().check(); - duellingPicklistComponent().should("have.attr", "disabled"); - checkBox().uncheck(); - duellingPicklistComponent().should("not.have.attr", "disabled"); - }); - }); - - describe("should render Duelling-Picklist with items grouped and a picklist divider", () => { - it("should verify Duelling-Picklist is displayed with divider", () => { - CypressMountWithProviders(); - - getDataElementByValue("picklist-divider"); - }); - - it("should verify Duelling-Picklist is displayed in groups with group label", () => { - CypressMountWithProviders(); - - picklistGroup().children().eq(0).should("have.text", "Group A"); - }); - - it("should verify all items in a group are added to assigned picklist when group add button is clicked", () => { - CypressMountWithProviders(); - - picklistGroup().children().eq(1).click(); - assignedPicklistItems().should("have.length", "3"); - picklistGroup().eq(2).children().eq(0).should("have.text", "Group A"); - }); - - it("should verify all items in a group are removed from assigned picklist when group remove button is clicked", () => { - CypressMountWithProviders(); - - picklistGroup().children().eq(1).click(); - unassignedPicklistItems().should("have.length", "3"); - assignedPicklistItems().should("have.length", "3"); - picklistGroup().eq(2).children().eq(1).click(); - unassignedPicklistItems().should("have.length", "6"); - assignedPicklistItems().should("have.length", "0"); - }); - }); - - describe("check events for Duelling-Picklist component", () => { - it("should call onChange when add button clicked", () => { - const callback: PicklistItemProps["onChange"] = cy.stub().as("onChange"); - CypressMountWithProviders( - - ); - - addButton(0).click(); - cy.get("@onChange").should("have.been.calledOnce"); - }); - - it("should call onChange when remove button clicked", () => { - const callback: PicklistItemProps["onChange"] = cy.stub().as("onChange"); - CypressMountWithProviders( - - ); - - removeButton(0).click(); - cy.get("@onChange").should("have.been.calledOnce"); - }); - - it.each([...keyToTrigger])( - "should call onChange when %s key pressed on add button", - (pressed) => { - const callback: PicklistItemProps["onChange"] = cy - .stub() - .as("onChange"); - CypressMountWithProviders( - - ); - - addButton(0).trigger("keydown", keyCode(pressed)); - cy.get("@onChange").should("have.been.calledOnce"); - } - ); - - it.each([...keyToTrigger])( - "should call onChange when %s key pressed on remove button", - (pressed) => { - const callback: PicklistItemProps["onChange"] = cy - .stub() - .as("onChange"); - CypressMountWithProviders( - - ); - - removeButton(0).trigger("keydown", keyCode(pressed)); - cy.get("@onChange").should("have.been.calledOnce"); - } - ); - }); - - describe("Accessibility tests for Duelling-Picklist component", () => { - it("should pass accessibility tests for Duelling-Picklist default story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist AlternativeSearch story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist Grouped story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist InDialog story", () => { - CypressMountWithProviders(); - - getDataElementByValue("main-text").click(); - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist AddItem story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist RemoveItem story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist Locked story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Duelling-Picklist CustomTooltipMessage story", () => { - CypressMountWithProviders(); - - getDataElementByValue("locked").realHover(); - cy.checkAccessibility(); - }); - - // FE-5711 - // eslint-disable-next-line jest/no-disabled-tests - describe.skip("skip", () => { - it("should pass accessibility tests for Duelling-Picklist disabled", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - }); - }); - - it("should render the items with the expected border radius styling", () => { - CypressMountWithProviders(); - - for (let i = 0; i < 5; i++) { - addButton(i).click(); - } - - unassignedPicklistItems().eq(0).should("have.css", "border-radius", "8px"); - unassignedPicklistItems().eq(1).should("have.css", "border-radius", "8px"); - unassignedPicklistItems().eq(2).should("have.css", "border-radius", "8px"); - unassignedPicklistItems().eq(3).should("have.css", "border-radius", "8px"); - unassignedPicklistItems().eq(4).should("have.css", "border-radius", "8px"); - - assignedPicklistItems().eq(0).should("have.css", "border-radius", "8px"); - assignedPicklistItems().eq(1).should("have.css", "border-radius", "8px"); - assignedPicklistItems().eq(2).should("have.css", "border-radius", "8px"); - assignedPicklistItems().eq(3).should("have.css", "border-radius", "8px"); - assignedPicklistItems().eq(4).should("have.css", "border-radius", "8px"); - }); -}); diff --git a/cypress/components/flat-table/flat-table.cy.tsx b/cypress/components/flat-table/flat-table.cy.tsx index ace2d473ad..b729fca4f8 100644 --- a/cypress/components/flat-table/flat-table.cy.tsx +++ b/cypress/components/flat-table/flat-table.cy.tsx @@ -526,7 +526,7 @@ context("Tests for Flat Table component", () => { it.skip("should render Flat Table with multiple sticky row headers, stickyAlignment set to right", () => { cy.viewport(700, 700); - CypressMountWithProviders(); + CypressMountWithProviders(); flatTableBodyRowByPosition(1) .find("td") @@ -2932,7 +2932,7 @@ context("Tests for Flat Table component", () => { it.skip("should render Flat Table with multiple sticky row headers for accessibility tests", () => { cy.viewport(700, 700); - CypressMountWithProviders(); + CypressMountWithProviders(); cy.checkAccessibility(); }); diff --git a/cypress/components/global-header/global-header.cy.tsx b/cypress/components/global-header/global-header.cy.tsx index 576a7c0a34..7f9ee0a3a0 100644 --- a/cypress/components/global-header/global-header.cy.tsx +++ b/cypress/components/global-header/global-header.cy.tsx @@ -1,7 +1,10 @@ /* eslint-disable jest/valid-expect, jest/valid-expect-in-promise */ import React from "react"; import GlobalHeader from "../../../src/components/global-header"; -import { FullMenuExample } from "../../../src/components/global-header/global-header-test.stories"; +import { + FullMenuExample, + GlobalHeaderWithErrorHandler, +} from "../../../src/components/global-header/global-header-test.stories"; import CypressMountWithProviders from "../../support/component-helper/cypress-mount"; import carbonLogo from "../../../logo/carbon-logo.png"; @@ -9,6 +12,12 @@ import navigationBar from "../../locators/navigation-bar"; import { globalHeader, globalHeaderLogo } from "../../locators/global-header"; context("Testing Global Header component", () => { + it("should not cause a ResizeObserver-related error to occur", () => { + CypressMountWithProviders(); + cy.wait(500); + cy.get("#error-div").should("have.text", ""); + }); + it("should check that z-index of component is greater than that of NavigationBar", () => { CypressMountWithProviders(); globalHeader().invoke("css", "zIndex").as("globalHeaderZIndex"); diff --git a/cypress/components/grouped-character/grouped-character.cy.tsx b/cypress/components/grouped-character/grouped-character.cy.tsx index efbd8f72b3..d8febd130f 100644 --- a/cypress/components/grouped-character/grouped-character.cy.tsx +++ b/cypress/components/grouped-character/grouped-character.cy.tsx @@ -148,7 +148,7 @@ context("Tests for GroupedCharacter component", () => { ["right", "end"], ["left", "start"], ] as [GroupedCharacterProps["labelAlign"], string][])( - "should use %s as labelAligment and render it with %s as css properties", + "should use %s as labelAlignment and render it with %s as css properties", (alignment, cssProp) => { CypressMountWithProviders( diff --git a/cypress/components/icon-button/icon-button.cy.tsx b/cypress/components/icon-button/icon-button.cy.tsx deleted file mode 100644 index f5c2cc94a7..0000000000 --- a/cypress/components/icon-button/icon-button.cy.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React from "react"; -import { IconButtonProps } from "components/icon-button"; -import CypressMountWithProviders from "../../support/component-helper/cypress-mount"; -import { icon } from "../../locators"; -import { IconButtonComponent } from "../../../src/components/icon-button/icon-button-test.stories"; -import { CHARACTERS } from "../../support/component-helper/constants"; -import { keyCode } from "../../support/helper"; - -const keyToTrigger = ["Space", "Enter"] as const; - -context("Tests for IconButton component", () => { - describe("when focused", () => { - it("should have the expected styling when the focusRedesignOptOut is false", () => { - CypressMountWithProviders( - - ); - - icon() - .parent() - .focus() - .should( - "have.css", - "box-shadow", - "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px" - ) - .and("have.css", "outline", "rgba(0, 0, 0, 0) solid 3px"); - }); - - it("should have the expected styling when the focusRedesignOptOut is true", () => { - CypressMountWithProviders( - , - undefined, - undefined, - { - focusRedesignOptOut: true, - } - ); - icon() - .parent() - .focus() - .should("have.css", "outline", "rgb(255, 188, 25) solid 3px"); - }); - }); - describe("check props for IconButton component", () => { - it("should render IconButton with aria-label prop", () => { - CypressMountWithProviders( - - ); - - icon() - .parent() - .should("have.attr", "aria-label", CHARACTERS.STANDARD) - .and("be.visible"); - }); - - it("should render IconButton with children prop", () => { - CypressMountWithProviders(); - - icon().should("be.visible"); - }); - - it("should render IconButton with disabled prop", () => { - CypressMountWithProviders(); - - icon().parent().should("be.disabled").and("have.attr", "disabled"); - }); - }); - - describe("check events for IconButton component", () => { - it("should call onBlur callback when a blur event is triggered", () => { - const callback: IconButtonProps["onBlur"] = cy.stub().as("onBlur"); - CypressMountWithProviders(); - - icon().parent().focus().blur(); - cy.get("@onBlur").should("have.been.calledOnce"); - }); - - it("should call onFocus callback when a focus event is triggered", () => { - const callback: IconButtonProps["onFocus"] = cy.stub().as("onFocus"); - CypressMountWithProviders(); - - icon().parent().focus(); - cy.get("@onFocus").should("have.been.calledOnce"); - }); - - it("should call onMouseEnter callback when a mouseover event is triggered", () => { - const callback: IconButtonProps["onMouseEnter"] = cy - .stub() - .as("onMouseEnter"); - CypressMountWithProviders( - - ); - - icon().parent().trigger("mouseover"); - cy.get("@onMouseEnter").should("have.been.calledOnce"); - }); - - it("should call onMouseLeave callback when a mouseout event is triggered", () => { - const callback: IconButtonProps["onMouseLeave"] = cy - .stub() - .as("onMouseLeave"); - CypressMountWithProviders( - - ); - - icon().parent().trigger("mouseover").trigger("mouseout"); - cy.get("@onMouseLeave").should("have.been.calledOnce"); - }); - - it("should call onClick callback when a click event is triggered", () => { - const callback: IconButtonProps["onClick"] = cy.stub().as("onClick"); - CypressMountWithProviders(); - - icon().parent().click(); - cy.get("@onClick").should("have.been.calledOnce"); - }); - - it.each([...keyToTrigger])( - "should call onClick callback when a keydown event is triggered with %s", - (key) => { - const callback: IconButtonProps["onClick"] = cy.stub().as("onClick"); - CypressMountWithProviders(); - - icon().parent().trigger("keydown", keyCode(key)); - cy.get("@onClick").should("have.been.calledOnce"); - } - ); - }); - - describe("check accessibility tests for IconButton component", () => { - it("should pass accessibility tests for aria-label prop", () => { - CypressMountWithProviders( - - ); - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for children prop", () => { - CypressMountWithProviders(); - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for disabled prop", () => { - CypressMountWithProviders(); - cy.checkAccessibility(); - }); - }); - - it("render with the expected border radius when roundness is %s", () => { - CypressMountWithProviders(); - - icon().parent().focus(); - icon().parent().should("have.css", "border-radius", "4px"); - }); -}); diff --git a/cypress/components/menu/menu.cy.tsx b/cypress/components/menu/menu.cy.tsx index 519cf0c60d..134a3394d8 100644 --- a/cypress/components/menu/menu.cy.tsx +++ b/cypress/components/menu/menu.cy.tsx @@ -63,6 +63,7 @@ import { MenuDividerComponent, InGlobalHeaderStory, } from "../../../src/components/menu/menu-test.stories"; +import { NavigationBarWithSubmenuAndChangingHeight } from "../../../src/components/navigation-bar/navigation-bar-test.stories"; const span = "span"; const div = "div"; @@ -333,12 +334,12 @@ context("Testing Menu component", () => { searchDefaultInput().tab(); searchCrossIcon().parent().should("have.focus"); - const bouding = (element: JQuery) => { + const bounding = (element: JQuery) => { return element[0].getBoundingClientRect(); }; searchCrossIcon() - .then(($el) => bouding($el)) + .then(($el) => bounding($el)) .as("position"); cy.get("@position") @@ -627,7 +628,7 @@ context("Testing Menu component", () => { "center", "flex-start", "flex-end", - ])("should verify Menu alignItmes is %s", (alignment) => { + ])("should verify Menu alignItems is %s", (alignment) => { CypressMountWithProviders(); menu().should("have.css", "align-items", alignment); @@ -1501,7 +1502,7 @@ context("Testing Menu component", () => { "text-top", "top", ])( - "should pass accessibility tests for Menu when alignItmes is %s", + "should pass accessibility tests for Menu when alignItems is %s", (alignment) => { CypressMountWithProviders(); @@ -1960,7 +1961,7 @@ context("Testing Menu component", () => { }); }); - describe("when inside a GlobalHeader", () => { + describe("when inside a Navigation Bar", () => { it("all the content of a long submenu can be accessed with the keyboard while remaining visible", () => { CypressMountWithProviders(); @@ -1978,5 +1979,32 @@ context("Testing Menu component", () => { '[data-component="submenu-wrapper"] ul > li:nth-child(20)' ); }); + + it("all the content of a long submenu can be accessed with the keyboard while remaining visible if the navbar height changes", () => { + CypressMountWithProviders(); + + cy.viewport(1000, 500); + + menuComponent(1).trigger("keydown", keyCode("downarrow")); + submenuItem(1).should("have.length", 21); + + // navigate to "change height" item and press it + for (let i = 0; i < 3; i++) { + cy.focused().trigger("keydown", keyCode("downarrow")); + } + cy.focused().trigger("keydown", keyCode("Enter")); + + // reopen menu and scroll to bottom with keyboard + cy.wait(100); + menuComponent(1).trigger("keydown", keyCode("downarrow")); + + for (let i = 0; i < 21; i++) { + cy.focused().trigger("keydown", keyCode("downarrow")); + } + + cy.checkInViewport( + '[data-component="submenu-wrapper"] ul > li:nth-child(21)' + ); + }); }); }); diff --git a/cypress/components/multi-action-button/multi-action-button.cy.tsx b/cypress/components/multi-action-button/multi-action-button.cy.tsx index 94e8225019..0ec9430e69 100644 --- a/cypress/components/multi-action-button/multi-action-button.cy.tsx +++ b/cypress/components/multi-action-button/multi-action-button.cy.tsx @@ -229,7 +229,7 @@ context("Tests for MultiActionButton component", () => { }); }); - describe("user interactions with MultiActionutton", () => { + describe("user interactions with MultiActionButton", () => { describe("pressing ArrowUp while MultiActionButton is open", () => { it("should move focus to previous child button and should not loop to last button when first is focused", () => { CypressMountWithProviders( @@ -448,7 +448,7 @@ context("Tests for MultiActionButton component", () => { }); }); - describe("user interactions with MultiActionutton when wrapping the child buttons in a custom component", () => { + describe("user interactions with MultiActionButton when wrapping the child buttons in a custom component", () => { describe("pressing ArrowUp while MultiActionButton is open", () => { it("should move focus to previous child button and should not loop to last button when first is focused", () => { CypressMountWithProviders( diff --git a/cypress/components/number/number.cy.tsx b/cypress/components/number/number.cy.tsx index c071bb7c9b..33f472cd16 100644 --- a/cypress/components/number/number.cy.tsx +++ b/cypress/components/number/number.cy.tsx @@ -178,7 +178,7 @@ context("Tests for Number component", () => { ["right", "end"], ["left", "start"], ] as [NumberProps["labelAlign"], string][])( - "should use %s as labelAligment and render it with flex-%s as css properties", + "should use %s as labelAlignment and render it with flex-%s as css properties", (alignment, cssProp) => { CypressMountWithProviders( diff --git a/cypress/components/pager/pager.cy.tsx b/cypress/components/pager/pager.cy.tsx index a41fa68ba7..47de7e6729 100644 --- a/cypress/components/pager/pager.cy.tsx +++ b/cypress/components/pager/pager.cy.tsx @@ -323,7 +323,7 @@ context("Test for Pager component", () => { }); }); - describe("check funtionality for Pager component", () => { + describe("check functionality for Pager component", () => { it.each([-1, -10, -100, ...testData])( "should set totalRecords out of scope to %s", (totalRecords) => { @@ -379,14 +379,14 @@ context("Test for Pager component", () => { viewportWidth, showItemsAssertion, firstAndLastArrowsAssertion, - totalRecordsAssetion + totalRecordsAssertion ) => { cy.viewport(viewportWidth, 768); CypressMountWithProviders(); showLabelBefore().should(showItemsAssertion); - pagerSummary().should(totalRecordsAssetion); + pagerSummary().should(totalRecordsAssertion); firstArrow().should(firstAndLastArrowsAssertion); lastArrow().should(firstAndLastArrowsAssertion); nextArrow().should("be.visible"); diff --git a/cypress/components/password/password.cy.tsx b/cypress/components/password/password.cy.tsx index 0bd2de5821..9b5a8a0b08 100644 --- a/cypress/components/password/password.cy.tsx +++ b/cypress/components/password/password.cy.tsx @@ -305,7 +305,7 @@ context("Tests for Password component", () => { ["right", "end"], ["left", "start"], ] as [PasswordProps["labelAlign"], string][])( - "should use %s as labelAligment and render it with %s as css properties", + "should use %s as labelAlignment and render it with %s as css properties", (alignment, cssProp) => { CypressMountWithProviders( diff --git a/cypress/components/switch/switch.cy.tsx b/cypress/components/switch/switch.cy.tsx index 2934318169..5ce15ffbfd 100644 --- a/cypress/components/switch/switch.cy.tsx +++ b/cypress/components/switch/switch.cy.tsx @@ -314,7 +314,7 @@ context("Testing Switch component", () => { ["warning", VALIDATION.WARNING], ["info", VALIDATION.INFO], ])( - "verify Switch component is verifyed with appropriate border color for validations", + "verify Switch component is verified with appropriate border color for validations", (type, validation) => { CypressMountWithProviders(); diff --git a/cypress/components/tooltip/tooltip.cy.tsx b/cypress/components/tooltip/tooltip.cy.tsx index 8cea855472..b4c876db14 100644 --- a/cypress/components/tooltip/tooltip.cy.tsx +++ b/cypress/components/tooltip/tooltip.cy.tsx @@ -208,14 +208,14 @@ context("Tests for Tooltip component", () => { }); describe("Accessibility tests for Tooltip component", () => { - it("should pass accessibilty tests for Tooltip Default story", () => { + it("should pass accessibility tests for Tooltip Default story", () => { CypressMountWithProviders(); getDataElementByValue("main-text").click(); cy.checkAccessibility(); }); - it("should pass accessibilty tests for Tooltip Controlled story", () => { + it("should pass accessibility tests for Tooltip Controlled story", () => { CypressMountWithProviders(); getDataElementByValue("main-text").eq(0).click(); @@ -228,7 +228,7 @@ context("Tests for Tooltip component", () => { ["left", 2], ["right", 3], ])( - "should pass accessibilty tests for Tooltip Positioning story %s position", + "should pass accessibility tests for Tooltip Positioning story %s position", (position, button) => { CypressMountWithProviders(); @@ -237,20 +237,20 @@ context("Tests for Tooltip component", () => { } ); - it("should pass accessibilty tests for Tooltip FlipBehviourOverrides story", () => { - CypressMountWithProviders(); + it("should pass accessibility tests for Tooltip FlipBehaviourOverrides story", () => { + CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should pass accessibilty tests for Tooltip LargeTooltip story", () => { + it("should pass accessibility tests for Tooltip LargeTooltip story", () => { CypressMountWithProviders(); getDataElementByValue("main-text").click(); cy.checkAccessibility(); }); - it("should pass accessibilty tests for Tooltip Types story", () => { + it("should pass accessibility tests for Tooltip Types story", () => { CypressMountWithProviders(); getDataElementByValue("main-text").eq(1).click(); @@ -259,7 +259,7 @@ context("Tests for Tooltip component", () => { cy.checkAccessibility(); }); - it("should pass accessibilty tests for Tooltip ColorOverrides story", () => { + it("should pass accessibility tests for Tooltip ColorOverrides story", () => { CypressMountWithProviders(); getDataElementByValue("main-text").click(); diff --git a/cypress/components/vertical-menu/vertical-menu.cy.tsx b/cypress/components/vertical-menu/vertical-menu.cy.tsx index 373ea6e42c..b756ae3961 100644 --- a/cypress/components/vertical-menu/vertical-menu.cy.tsx +++ b/cypress/components/vertical-menu/vertical-menu.cy.tsx @@ -487,20 +487,20 @@ context("Testing Vertical Menu component", () => { }); describe("should check the accessibility tests", () => { - it("should check accessiblity for verticalMenuComponent", () => { + it("should check accessibility for verticalMenuComponent", () => { CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent open", () => { + it("should check accessibility for verticalMenuComponent open", () => { CypressMountWithProviders(); verticalMenuItem().tab().tab().click(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent Active", () => { + it("should check accessibility for verticalMenuComponent Active", () => { CypressMountWithProviders( !isOpen} /> ); @@ -508,32 +508,32 @@ context("Testing Vertical Menu component", () => { cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent Adornment", () => { + it("should check accessibility for verticalMenuComponent Adornment", () => { CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent CustomItemHeight", () => { + it("should check accessibility for verticalMenuComponent CustomItemHeight", () => { CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent CustomItemPadding", () => { + it("should check accessibility for verticalMenuComponent CustomItemPadding", () => { CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent FullScreen", () => { + it("should check accessibility for verticalMenuComponent FullScreen", () => { cy.viewport(320, 599); CypressMountWithProviders(); cy.checkAccessibility(); }); - it("should check accessiblity for verticalMenuComponent FullScreen open", () => { + it("should check accessibility for verticalMenuComponent FullScreen open", () => { cy.viewport(320, 599); CypressMountWithProviders(); diff --git a/cypress/locators/duelling-picklist/index.js b/cypress/locators/duelling-picklist/index.js deleted file mode 100644 index 7749fb442b..0000000000 --- a/cypress/locators/duelling-picklist/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import { - DUELLING_PICKLIST_COMPONENT, - PICKLIST, - PICKLIST_ITEMS, - PICKLIST_LEFT_LABEL, - PICKLIST_RIGHT_LABEL, - PICKLIST_GROUP, -} from "./locators"; -import { SEARCH_COMPONENT } from "../search/locators"; -import { CHECKBOXROLE } from "../checkbox/locators"; - -// component preview locators -export const duellingPicklistComponent = () => - cy.get(DUELLING_PICKLIST_COMPONENT); -export const picklist = () => cy.get(PICKLIST); -export const unassignedPicklist = () => picklist().eq(0); -export const unassignedPicklistItems = () => - unassignedPicklist().find(PICKLIST_ITEMS); -export const assignedPicklist = () => picklist().eq(1); -export const assignedPicklistItems = () => - assignedPicklist().find(PICKLIST_ITEMS); -export const picklistRightLabel = () => cy.get(PICKLIST_RIGHT_LABEL); -export const picklistLeftLabel = () => cy.get(PICKLIST_LEFT_LABEL); -export const addButton = (index) => - unassignedPicklistItems().eq(index).find("button"); -export const removeButton = (index) => - assignedPicklistItems().eq(index).find("button"); -export const duellingSearchInput = () => cy.get(SEARCH_COMPONENT).find("input"); -export const checkBox = () => cy.get(CHECKBOXROLE); -export const picklistGroup = () => cy.get(PICKLIST_GROUP); diff --git a/cypress/locators/duelling-picklist/locators.js b/cypress/locators/duelling-picklist/locators.js deleted file mode 100644 index 4f554f33c1..0000000000 --- a/cypress/locators/duelling-picklist/locators.js +++ /dev/null @@ -1,7 +0,0 @@ -export const DUELLING_PICKLIST_COMPONENT = - '[data-component="duelling-picklist"]'; -export const PICKLIST = '[data-element="picklist"]'; -export const PICKLIST_ITEMS = '[data-element="picklist-item"]'; -export const PICKLIST_LEFT_LABEL = '[data-element="picklist-left-label"]'; -export const PICKLIST_RIGHT_LABEL = '[data-element="picklist-right-label"]'; -export const PICKLIST_GROUP = '[data-element="picklist-group"]'; diff --git a/cypress/support/helper.ts b/cypress/support/helper.ts index 44a660bcbe..12aaa95472 100644 --- a/cypress/support/helper.ts +++ b/cypress/support/helper.ts @@ -65,7 +65,7 @@ const keys = { uparrow: { key: "ArrowUp", keyCode: 38, which: 38 }, leftarrow: { key: "ArrowLeft", keyCode: 37, which: 37 }, rightarrow: { key: "ArrowRight", keyCode: 39, which: 39 }, - Enter: { key: "Enter", keyCode: 13, which: 13 }, + Enter: { key: "Enter", keyCode: 13, which: 13, bubbles: true }, EnterForce: { key: "Enter", keyCode: 13, which: 13, force: true }, Space: { key: " ", keyCode: 32, which: 32 }, Tab: { key: "Tab", keyCode: 9, which: 9 }, @@ -76,8 +76,11 @@ const keys = { pagedown: { key: "PageDown", keyCode: 34, which: 34 }, pageup: { key: "PageUp", keyCode: 33, which: 33 }, }; + +export type KeyIds = keyof typeof keys; + export function keyCode( - type: keyof typeof keys + type: KeyIds ): { key: string; keyCode: number; diff --git a/package-lock.json b/package-lock.json index 0820e42b25..ec8dc35759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "carbon-react", - "version": "123.1.0", + "version": "123.4.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "carbon-react", - "version": "123.1.0", + "version": "123.4.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@actions/github": "^5.1.1", "@axe-core/playwright": "^4.7.3", "@floating-ui/dom": "^1.2.7", "@floating-ui/react-dom": "^1.3.0", @@ -173,21 +172,11 @@ "uuid": "^8.3.2" } }, - "node_modules/@actions/github": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", - "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", - "dependencies": { - "@actions/http-client": "^2.0.1", - "@octokit/core": "^3.6.0", - "@octokit/plugin-paginate-rest": "^2.17.0", - "@octokit/plugin-rest-endpoint-methods": "^5.13.0" - } - }, "node_modules/@actions/http-client": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", "integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", + "dev": true, "dependencies": { "tunnel": "^0.0.6" } @@ -14080,32 +14069,35 @@ "dev": true }, "node_modules/browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "dependencies": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 4" } }, "node_modules/browserify-sign/node_modules/bn.js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", - "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", "dev": true }, "node_modules/browserify-sign/node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "dependencies": { "inherits": "^2.0.3", @@ -16425,9 +16417,9 @@ } }, "node_modules/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/crypto-random-string": { "version": "2.0.0", @@ -38451,6 +38443,7 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } @@ -40310,21 +40303,11 @@ "uuid": "^8.3.2" } }, - "@actions/github": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", - "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", - "requires": { - "@actions/http-client": "^2.0.1", - "@octokit/core": "^3.6.0", - "@octokit/plugin-paginate-rest": "^2.17.0", - "@octokit/plugin-rest-endpoint-methods": "^5.13.0" - } - }, "@actions/http-client": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", "integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", + "dev": true, "requires": { "tunnel": "^0.0.6" } @@ -50772,32 +50755,32 @@ } }, "browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "requires": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" }, "dependencies": { "bn.js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", - "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", "dev": true }, "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -52564,9 +52547,9 @@ } }, "crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "crypto-random-string": { "version": "2.0.0", @@ -69483,7 +69466,8 @@ "tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true }, "tunnel-agent": { "version": "0.6.0", diff --git a/package.json b/package.json index 77aae2f621..e020731652 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carbon-react", - "version": "123.1.0", + "version": "123.4.1", "description": "A library of reusable React components for easily building user interfaces.", "files": [ "lib", diff --git a/playwright/components/index.ts b/playwright/components/index.ts index d98765264b..2949a58c58 100644 --- a/playwright/components/index.ts +++ b/playwright/components/index.ts @@ -11,6 +11,7 @@ import { STICKY_FOOTER, COMMMON_DATA_ELEMENT_INPUT, PORTAL, + BUTTON, } from "./locators"; export const icon = (page: Page) => { @@ -25,6 +26,10 @@ export const commonDataElementInputPreview = (page: Page) => { return page.locator(COMMMON_DATA_ELEMENT_INPUT); }; +export const button = (page: Page) => { + return page.locator(BUTTON); +}; + export const closeIconButton = (page: Page) => { return page.locator(CLOSE_ICON_BUTTON); }; diff --git a/playwright/components/search/index.ts b/playwright/components/search/index.ts new file mode 100644 index 0000000000..36dffd3daa --- /dev/null +++ b/playwright/components/search/index.ts @@ -0,0 +1,19 @@ +import { Page } from "playwright-core"; +import { SEARCH_COMPONENT, CROSS_ICON, SEARCH_ICON } from "./locators"; +import { BUTTON_DATA_COMPONENT_PREVIEW } from "../button/locators"; +import { BUTTON } from "../locators"; +import { getDataElementByValue } from ".."; + +// component preview locators +export const searchDefault = (page: Page) => page.locator(SEARCH_COMPONENT); +export const searchDefaultInput = (page: Page) => + searchDefault(page).locator("input"); +export const searchDefaultInnerIcon = (page: Page) => + searchDefault(page).locator("span:nth-child(1)"); +export const searchCrossIcon = (page: Page) => + searchDefault(page).locator(CROSS_ICON); +export const searchButton = (page: Page) => + searchDefault(page).locator(BUTTON_DATA_COMPONENT_PREVIEW); +export const searchIcon = (page: Page) => page.locator(BUTTON); +export const searchFindIcon = (page: Page) => + getDataElementByValue(page, SEARCH_ICON); diff --git a/playwright/components/search/locators.ts b/playwright/components/search/locators.ts new file mode 100644 index 0000000000..6097525c18 --- /dev/null +++ b/playwright/components/search/locators.ts @@ -0,0 +1,4 @@ +// component preview locators +export const SEARCH_COMPONENT = '[data-component="search"]'; +export const CROSS_ICON = '[data-element="cross"]'; +export const SEARCH_ICON = "search"; diff --git a/playwright/components/step-sequence/index.ts b/playwright/components/step-sequence/index.ts index 671aa7bb5b..3607b1b76b 100644 --- a/playwright/components/step-sequence/index.ts +++ b/playwright/components/step-sequence/index.ts @@ -11,6 +11,6 @@ export const stepSequenceItemIndicator = (page: Page) => 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/support/helper.ts b/playwright/support/helper.ts index f33aa191f1..1822df2625 100644 --- a/playwright/support/helper.ts +++ b/playwright/support/helper.ts @@ -350,3 +350,8 @@ export const continuePressingSHIFTTAB = async (page: Page, count: number) => { await Promise.all(promises); }; + +export const waitForAnimationEnd = (locator: Locator) => + locator.evaluate((element) => + Promise.all(element.getAnimations().map((animation) => animation.finished)) + ); diff --git a/src/components/action-popover/action-popover.pw.tsx b/src/components/action-popover/action-popover.pw.tsx index 4b72de5319..5a16203c74 100644 --- a/src/components/action-popover/action-popover.pw.tsx +++ b/src/components/action-popover/action-popover.pw.tsx @@ -38,8 +38,8 @@ import { ActionPopoverComponentIcons, ActionPopoverComponentInFlatTable, ActionPopoverComponentInOverflowHiddenContainer, - ActionPopoverComponentKeyboardNaviationLeftAlignedSubmenu, - ActionPopoverComponentKeyboardNaviationRightAlignedSubmenu, + ActionPopoverComponentKeyboardNavigationLeftAlignedSubmenu, + ActionPopoverComponentKeyboardNavigationRightAlignedSubmenu, ActionPopoverComponentKeyboardNavigation, ActionPopoverComponentMenuOpeningAbove, ActionPopoverComponentMenuRightAligned, @@ -1476,7 +1476,7 @@ test.describe("Accessibility tests for ActionPopover", () => { mount, page, }) => { - await mount(); + await mount(); const actionPopoverButtonElement = await actionPopoverButton(page).nth(0); await actionPopoverButtonElement.click(); const submenuTrigger = await actionPopoverInnerItem(page, 0); @@ -1488,7 +1488,9 @@ test.describe("Accessibility tests for ActionPopover", () => { mount, page, }) => { - await mount(); + await mount( + + ); const actionPopoverButtonElement = await actionPopoverButton(page).nth(0); await actionPopoverButtonElement.click(); const submenuTrigger = await actionPopoverInnerItem(page, 0); diff --git a/src/components/action-popover/action-popover.stories.mdx b/src/components/action-popover/action-popover.stories.mdx index 992ff7085a..90871ce45c 100644 --- a/src/components/action-popover/action-popover.stories.mdx +++ b/src/components/action-popover/action-popover.stories.mdx @@ -184,7 +184,7 @@ the sub-menu. Pressing the "right" key will return focus back to the main menu a triggering an action. - + ### Keyboard navigation right aligned submenu @@ -196,7 +196,7 @@ is on a item will open a sub-menu if it has one and pressing the "left" key will diff --git a/src/components/action-popover/action-popover.stories.tsx b/src/components/action-popover/action-popover.stories.tsx index 56e6ebd49e..ada449a648 100644 --- a/src/components/action-popover/action-popover.stories.tsx +++ b/src/components/action-popover/action-popover.stories.tsx @@ -406,7 +406,7 @@ export const ActionPopoverComponentKeyboardNavigation: ComponentStory< ); }; -export const ActionPopoverComponentKeyboardNaviationLeftAlignedSubmenu: ComponentStory< +export const ActionPopoverComponentKeyboardNavigationLeftAlignedSubmenu: ComponentStory< typeof ActionPopover > = () => { return ( @@ -455,7 +455,7 @@ export const ActionPopoverComponentKeyboardNaviationLeftAlignedSubmenu: Componen ); }; -export const ActionPopoverComponentKeyboardNaviationRightAlignedSubmenu: ComponentStory< +export const ActionPopoverComponentKeyboardNavigationRightAlignedSubmenu: ComponentStory< typeof ActionPopover > = () => { return ( diff --git a/src/components/button-bar/button-bar.spec.tsx b/src/components/button-bar/button-bar.spec.tsx index 668004b6bf..0591c6083b 100644 --- a/src/components/button-bar/button-bar.spec.tsx +++ b/src/components/button-bar/button-bar.spec.tsx @@ -47,7 +47,7 @@ describe("Button Bar", () => { }); }); - describe("when props are passed to the compontent", () => { + describe("when props are passed to the component", () => { it("renders proper props and children", () => { const wrapper = renderButtonBar("Large", 3, { size: "large", diff --git a/src/components/button-minor/button-minor.pw.tsx b/src/components/button-minor/button-minor.pw.tsx index 17f47d9a0c..da504e4683 100644 --- a/src/components/button-minor/button-minor.pw.tsx +++ b/src/components/button-minor/button-minor.pw.tsx @@ -541,7 +541,7 @@ test.describe("accessibility tests", () => { await checkAccessibility(page); }); - test("should check accessibility for secondary destrictive Button Minor", async ({ + test("should check accessibility for secondary destructive Button Minor", async ({ mount, page, }) => { diff --git a/src/components/button-toggle/button-toggle.spec.tsx b/src/components/button-toggle/button-toggle.spec.tsx index 061990a31f..0b3c4d8f2b 100644 --- a/src/components/button-toggle/button-toggle.spec.tsx +++ b/src/components/button-toggle/button-toggle.spec.tsx @@ -317,7 +317,7 @@ describe("ButtonToggle", () => { ); - // Uses snapshot as jest/enzyme doesnt support :first-of-type + // Uses snapshot as jest/enzyme doesn't support :first-of-type expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/components/confirm/confirm.component.tsx b/src/components/confirm/confirm.component.tsx index 76050757cd..6c5b35045c 100644 --- a/src/components/confirm/confirm.component.tsx +++ b/src/components/confirm/confirm.component.tsx @@ -8,6 +8,7 @@ import Button from "../button/button.component"; import Icon, { IconType } from "../icon"; import Loader from "../loader"; import useLocale from "../../hooks/__internal__/useLocale"; +import tagComponent, { TagProps } from "../../__internal__/utils/helpers/tags"; export interface ConfirmProps extends Omit< @@ -55,6 +56,10 @@ export interface ConfirmProps confirmButtonIconPosition?: "before" | "after"; /** Defines an Icon type within the confirm button (see Icon for options) */ confirmButtonIconType?: IconType; + /** Data tag prop bag for cancelButton */ + cancelButtonDataProps?: TagProps; + /** Data tag prop bag for confirmButton */ + confirmButtonDataProps?: TagProps; /** Makes cancel button disabled */ disableCancel?: boolean; /** Makes confirm button disabled */ @@ -81,6 +86,8 @@ export const Confirm = ({ cancelButtonIconPosition, confirmButtonIconType, confirmButtonIconPosition, + cancelButtonDataProps, + confirmButtonDataProps, cancelLabel, onCancel, disableCancel, @@ -122,12 +129,15 @@ export const Confirm = ({ ev: React.MouseEvent ) => void } - data-element="cancel" buttonType={cancelButtonType} destructive={cancelButtonDestructive} disabled={disableCancel} iconType={cancelButtonIconType} iconPosition={cancelButtonIconPosition} + {...tagComponent("cancel", { + "data-element": "cancel", + ...cancelButtonDataProps, + })} > {cancelLabel || l.confirm.no()} @@ -142,13 +152,16 @@ export const Confirm = ({ ev: React.MouseEvent ) => void } - data-element="confirm" buttonType={confirmButtonType} destructive={confirmButtonDestructive} disabled={isLoadingConfirm || disableConfirm} ml={2} iconType={confirmButtonIconType} iconPosition={confirmButtonIconPosition} + {...tagComponent("confirm", { + "data-element": "confirm", + ...confirmButtonDataProps, + })} > {isLoadingConfirm ? ( diff --git a/src/components/confirm/confirm.pw.tsx b/src/components/confirm/confirm.pw.tsx index aa02dbd6fe..6f3da3858a 100644 --- a/src/components/confirm/confirm.pw.tsx +++ b/src/components/confirm/confirm.pw.tsx @@ -495,6 +495,38 @@ test.describe("should render Confirm component", () => { "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px" ); }); + + test(`should check custom data tags are correctly applied to their respective buttons`, async ({ + mount, + page, + }) => { + await mount( + {}} + onConfirm={() => {}} + cancelButtonDataProps={{ + "data-element": "bang", + "data-role": "wallop", + }} + confirmButtonDataProps={{ + "data-element": "bar", + "data-role": "wiz", + }} + open + /> + ); + + const cancelButton = getDataElementByValue(page, "bang"); + const confirmButton = getDataElementByValue(page, "bar"); + + await expect(cancelButton).toHaveAttribute("data-component", "cancel"); + await expect(cancelButton).toHaveAttribute("data-element", "bang"); + await expect(cancelButton).toHaveAttribute("data-role", "wallop"); + + await expect(confirmButton).toHaveAttribute("data-component", "confirm"); + await expect(confirmButton).toHaveAttribute("data-element", "bar"); + await expect(confirmButton).toHaveAttribute("data-role", "wiz"); + }); }); test.describe("should render Confirm component for event tests", () => { diff --git a/src/components/confirm/confirm.spec.tsx b/src/components/confirm/confirm.spec.tsx index 434783f602..2d451b69b3 100644 --- a/src/components/confirm/confirm.spec.tsx +++ b/src/components/confirm/confirm.spec.tsx @@ -14,6 +14,7 @@ import IconButton from "../icon-button"; import StyledIconButton from "../icon-button/icon-button.style"; import { StyledDialog } from "../dialog/dialog.style"; import Logger from "../../__internal__/utils/logger"; +import { rootTagTest } from "../../__internal__/utils/helpers/tags/tags-specs"; // mock Logger.deprecate so that no console warnings occur while running the tests const loggerSpy = jest.spyOn(Logger, "deprecate"); @@ -490,4 +491,35 @@ describe("Confirm", () => { ) ); }); + + it("has proper data attributes applied to elements", () => { + wrapper = mount( + {}} + onConfirm={() => {}} + cancelButtonDataProps={{ + "data-element": "bang", + "data-role": "wallop", + }} + confirmButtonDataProps={{ + "data-element": "bar", + "data-role": "wiz", + }} + open + /> + ); + const cancelButton = wrapper + .find(Button) + .filter('[data-component="cancel"]') + .at(0); + + const confirmButton = wrapper + .find(Button) + .filter('[data-component="confirm"]') + .at(0); + + rootTagTest(cancelButton, "cancel", "bang", "wallop"); + rootTagTest(confirmButton, "confirm", "bar", "wiz"); + }); }); diff --git a/src/components/confirm/confirm.stories.mdx b/src/components/confirm/confirm.stories.mdx index 2d609af717..57eb29a0db 100644 --- a/src/components/confirm/confirm.stories.mdx +++ b/src/components/confirm/confirm.stories.mdx @@ -106,6 +106,12 @@ Allows to set variant which is supported in ` + setIsOpen(false)} + onCancel={() => setIsOpen(false)} + cancelButtonDataProps={{ + "data-element": "bang", + "data-role": "wallop", + }} + confirmButtonDataProps={{ + "data-element": "bar", + "data-role": "wiz", + }} + > + Content + + + ); +}; + export const SingleAction = () => { const [isOpen, setIsOpen] = useState(defaultOpenState); return ( diff --git a/src/components/date-range/date-range.stories.mdx b/src/components/date-range/date-range.stories.mdx index fed786508c..ca1029bfcd 100644 --- a/src/components/date-range/date-range.stories.mdx +++ b/src/components/date-range/date-range.stories.mdx @@ -141,5 +141,19 @@ The following keys are available to override the translations for this component type: "func", returnType: "date-fns locale object", }, + { + name: "date.ariaLabels.previousMonthButton", + description: + "Aria label text for the previous month navigation button in the date picker", + type: "func", + returnType: "string", + }, + { + name: "date.ariaLabels.nextMonthButton", + description: + "Aria label text for the next month navigation button in the date picker", + type: "func", + returnType: "string", + } ]} /> diff --git a/src/components/date-range/date-range.stories.tsx b/src/components/date-range/date-range.stories.tsx index 73aeadce11..a8af9bd2bb 100644 --- a/src/components/date-range/date-range.stories.tsx +++ b/src/components/date-range/date-range.stories.tsx @@ -285,7 +285,13 @@ export const LocaleOverrideExampleImplementation: ComponentStory< "fr-FR", - date: { dateFnsLocale: () => fr }, + date: { + dateFnsLocale: () => fr, + ariaLabels: { + previousMonthButton: () => "Mois précédent", + nextMonthButton: () => "Mois prochain", + }, + }, }} > = { current?: T | null; @@ -51,6 +53,10 @@ export interface DatePickerProps { open?: boolean; /** Callback triggered when a Day is clicked */ onDayClick?: (date: Date, ev: React.MouseEvent) => void; + /** Sets the picker open state */ + setOpen: (isOpen: boolean) => void; + /** Id passed to tab guard element */ + pickerTabGuardId?: string; } const popoverMiddleware = [ @@ -60,121 +66,209 @@ const popoverMiddleware = [ }), ]; -export const DatePicker = React.forwardRef( - ( - { - inputElement, - minDate, - maxDate, - selectedDays, - disablePortal, - onDayClick, - pickerMouseDown, - pickerProps, - open, - }: DatePickerProps, - ref - ) => { - const l = useLocale(); - const { localize, options } = l.date.dateFnsLocale(); - const { weekStartsOn } = options || /* istanbul ignore next */ {}; - const monthsLong = useMemo( - () => - Array.from({ length: 12 }).map((_, i) => { - const month = localize?.month(i); - return month[0].toUpperCase() + month.slice(1); - }), - [localize] - ); - const monthsShort = useMemo( - () => - Array.from({ length: 12 }).map((_, i) => - localize?.month(i, { width: "abbreviated" }).substring(0, 3) - ), - [localize] +export const DatePicker = ({ + inputElement, + minDate, + maxDate, + selectedDays, + disablePortal, + onDayClick, + pickerMouseDown, + pickerProps, + open, + setOpen, + pickerTabGuardId, +}: DatePickerProps) => { + const l = useLocale(); + const { localize, options } = l.date.dateFnsLocale(); + const { weekStartsOn } = options || /* istanbul ignore next */ {}; + const monthsLong = useMemo( + () => + Array.from({ length: 12 }).map((_, i) => { + const month = localize?.month(i); + return month[0].toUpperCase() + month.slice(1); + }), + [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] + ); + const weekdaysShort = useMemo(() => { + const isGivenLocale = (str: string) => l.locale().includes(str); + return Array.from({ length: 7 }).map((_, i) => + localize + ?.day( + i, + ["de", "pl"].some(isGivenLocale) + ? { width: "wide" } + : { width: "abbreviated" } + ) + .substring(0, isGivenLocale("de") ? 2 : 3) ); - const weekdaysLong = useMemo( - () => Array.from({ length: 7 }).map((_, i) => localize?.day(i)), - [localize] - ); - const weekdaysShort = useMemo(() => { - const isGivenLocale = (str: string) => l.locale().includes(str); - return Array.from({ length: 7 }).map((_, i) => - localize - ?.day( - i, - ["de", "pl"].some(isGivenLocale) - ? { width: "wide" } - : { width: "abbreviated" } - ) - .substring(0, isGivenLocale("de") ? 2 : 3) + }, [l, 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']" ); - }, [l, localize]); - - const handleDayClick = ( - date: Date, - modifiers: DayModifiers, - ev: React.MouseEvent - ) => { - if (!modifiers.disabled) { - const { id, name } = inputElement?.current - ?.firstChild as HTMLInputElement; - ev.target = { - ...ev.target, - id, - name, - } as HTMLInputElement; - onDayClick?.(date, ev); + + /* istanbul ignore else */ + if (selectedDay && firstDay !== selectedDay) { + selectedDay?.setAttribute("tabindex", "0"); + firstDay?.setAttribute("tabindex", "-1"); } - }; + } + }, [open]); - const formatDay = (date: Date) => - `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ - monthsShort[date.getMonth()] - } ${date.getFullYear()}`; + const handleDayClick = ( + date: Date, + modifiers: DayModifiers, + ev: React.MouseEvent + ) => { + if (!modifiers.disabled) { + const { id, name } = inputElement?.current + ?.firstChild as HTMLInputElement; + ev.target = { + ...ev.target, + id, + name, + } as HTMLInputElement; + onDayClick?.(date, ev); + } + }; - if (!open) { - return null; + const handleOnKeyDown = (ev: React.KeyboardEvent) => { + if (Events.isEscKey(ev)) { + inputElement.current?.querySelector("input")?.focus(); + setOpen(false); } - const localeUtils = { formatDay } as LocaleUtils; + if ( + ref.current?.querySelector(".DayPicker-NavBar button") === + document.activeElement && + Events.isTabKey(ev) && + Events.isShiftKey(ev) + ) { + ev.preventDefault(); + setOpen(false); + inputElement.current?.querySelector("input")?.focus(); + } + }; - return ( - - - { - const { className, weekday } = weekdayElementProps; - - return ( - - {weekdaysShort[weekday]} - - ); - }} - navbarElement={} - fixedWeeks - initialMonth={selectedDays || undefined} - disabledDays={getDisabledDays(minDate, maxDate)} - locale={l.locale()} - localeUtils={localeUtils} - {...pickerProps} - /> - - - ); + const handleOnDayKeyDown = ( + _day: Date, + _modifiers: DayModifiers, + ev: React.KeyboardEvent + ) => { + // we need to manually handle this as the picker may be in a Portal + /* istanbul ignore else */ + if (Events.isTabKey(ev) && !Events.isShiftKey(ev)) { + ev.preventDefault(); + setOpen(false); + const input = inputElement.current?.querySelector("input"); + + /* istanbul ignore else */ + if (input) { + const elements = Array.from( + document.querySelectorAll(defaultFocusableSelectors) || + /* istanbul ignore next */ [] + ) as HTMLElement[]; + const elementsInPicker = Array.from( + ref.current?.querySelectorAll("button, [tabindex]") || + /* istanbul ignore next */ [] + ) as HTMLElement[]; + const filteredElements = elements.filter( + (el) => Number(el.tabIndex) !== -1 && !elementsInPicker.includes(el) + ); + const nextIndex = filteredElements.indexOf(input as HTMLElement) + 1; + filteredElements[nextIndex]?.focus(); + } + } + }; + + const formatDay = (date: Date) => + `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ + monthsShort[date.getMonth()] + } ${date.getFullYear()}`; + + if (!open) { + return null; } -); + + const localeUtils = { formatDay } as LocaleUtils; + + const handleTabGuardFocus = () => { + ref.current?.querySelector("button")?.focus(); + }; + + return ( + + +
+ { + const { className, weekday } = weekdayElementProps; + + return ( + + {weekdaysShort[weekday]} + + ); + }} + navbarElement={} + fixedWeeks + initialMonth={selectedDays || undefined} + disabledDays={getDisabledDays(minDate, maxDate)} + locale={l.locale()} + localeUtils={localeUtils} + onDayKeyDown={handleOnDayKeyDown} + {...pickerProps} + /> + + + ); +}; DatePicker.displayName = "DatePicker"; diff --git a/src/components/date/__internal__/date-picker/date-picker.spec.tsx b/src/components/date/__internal__/date-picker/date-picker.spec.tsx index 70e2e5357d..d29c4d99dc 100644 --- a/src/components/date/__internal__/date-picker/date-picker.spec.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.spec.tsx @@ -61,16 +61,19 @@ const MockComponent = (props: MockProps) => { function renderI18n({ locale, ...props -}: { locale?: Partial } & Omit) { +}: { locale?: Partial } & Omit< + DatePickerProps, + "inputElement" | "setOpen" +>) { return mount( - + {}} /> ); } -function render(props: Omit) { - return mount(); +function render(props: Omit) { + return mount( {}} />); } describe("DatePicker", () => { @@ -162,7 +165,7 @@ describe("DatePicker", () => { }); }); - describe('when the "onDayClick" prop have been triggered', () => { + describe('when the "onDayClick" prop has been triggered', () => { let onDayClickFn: jest.Mock; beforeEach(() => { @@ -225,7 +228,13 @@ describe("StyledDayPicker", () => { const buildLocale = (l: keyof typeof translations) => ({ locale: () => l, - date: { dateFnsLocale: () => translations[l] }, + date: { + dateFnsLocale: () => translations[l], + ariaLabels: { + previousMonthButton: () => "foo", + nextMonthButton: () => "foo", + }, + }, }); type WeekdaysType = { long?: string[]; short?: string[] }; 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 55364c2b7a..8cdf8d4b86 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,4 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; import baseTheme from "../../../../style/themes/base"; import addFocusStyling from "../../../../style/utils/add-focus-styling"; @@ -187,7 +187,13 @@ const StyledDayPicker = styled.div` position: absolute; height: 346px; width: 352px; - z-index: ${({ theme }) => theme.zIndex.popover}; + ${({ theme }) => css` + z-index: ${theme.zIndex.popover}; + ${!theme.focusRedesignOptOut && + ` + margin-top: var(--spacing050); + `} + `} .DayPicker { z-index: 1000; diff --git a/src/components/date/__internal__/navbar/navbar.component.tsx b/src/components/date/__internal__/navbar/navbar.component.tsx index 9cd5cd1ec9..1732c337f8 100644 --- a/src/components/date/__internal__/navbar/navbar.component.tsx +++ b/src/components/date/__internal__/navbar/navbar.component.tsx @@ -2,6 +2,8 @@ import React from "react"; import StyledButton from "./button.style"; import StyledNavbar from "./navbar.style"; import Icon from "../../../icon"; +import Events from "../../../../__internal__/utils/helpers/events"; +import useLocale from "../../../../hooks/__internal__/useLocale"; export interface NavbarProps { onPreviousClick?: () => void; @@ -13,15 +15,40 @@ export const Navbar = ({ onPreviousClick, onNextClick, className, -}: NavbarProps) => ( - - onPreviousClick?.()}> - - - onNextClick?.()}> - - - -); +}: NavbarProps) => { + const locale = useLocale(); + const { previousMonthButton, nextMonthButton } = locale.date.ariaLabels; + + const handleKeyDown = (ev: React.KeyboardEvent) => { + if ( + Events.isLeftKey(ev) || + Events.isRightKey(ev) || + Events.isUpKey(ev) || + Events.isDownKey(ev) + ) { + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + return ( + + onPreviousClick?.()} + onKeyDown={handleKeyDown} + > + + + onNextClick?.()} + onKeyDown={handleKeyDown} + > + + + + ); +}; export default Navbar; diff --git a/src/components/date/__internal__/navbar/navbar.spec.tsx b/src/components/date/__internal__/navbar/navbar.spec.tsx index 346d97934a..833b644d3e 100644 --- a/src/components/date/__internal__/navbar/navbar.spec.tsx +++ b/src/components/date/__internal__/navbar/navbar.spec.tsx @@ -1,12 +1,15 @@ import React from "react"; import TestRenderer from "react-test-renderer"; -import { shallow, ShallowWrapper } from "enzyme"; +import { mount, ReactWrapper, shallow, ShallowWrapper } from "enzyme"; import Navbar, { NavbarProps } from "./navbar.component"; import StyledButton from "./button.style"; +const arrowKeys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]; +const actionKeys = ["Enter", "Space"]; + describe("Navbar", () => { - let wrapper: ShallowWrapper; + let wrapper: ShallowWrapper | ReactWrapper; let onPreviousClick: jest.Mock; let onNextClick: jest.Mock; @@ -30,14 +33,138 @@ describe("Navbar", () => { }); it("returns a next button that calls onNextClick", () => { - const prevButton = wrapper.find(StyledButton).at(1); - prevButton.simulate("click"); + const nextButton = wrapper.find(StyledButton).at(1); + nextButton.simulate("click"); expect(onNextClick.mock.calls.length).toEqual(1); }); it("applies the custom class name", () => { expect(wrapper.find(".custom-class").length).toEqual(1); }); + + it("applies the expected aria-labels to the buttons", () => { + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0).getDOMNode(); + const nextButton = wrapper.find(StyledButton).at(1).getDOMNode(); + + expect(prevButton.getAttribute("aria-label")).toBe("Previous month"); + expect(nextButton.getAttribute("aria-label")).toBe("Next month"); + }); + + it.each(arrowKeys)( + "does not change the current month when %s key is pressed and previous button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0); + (prevButton.getDOMNode() as HTMLElement).focus(); + prevButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + } + ); + + it.each(arrowKeys)( + "does not change the current month when %s key is pressed and next button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const nextButton = wrapper.find(StyledButton).at(1); + (nextButton.getDOMNode() as HTMLElement).focus(); + nextButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + } + ); + + it.each(actionKeys)( + "changes the current month when %s key is pressed and previous button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0); + (prevButton.getDOMNode() as HTMLElement).focus(); + prevButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + } + ); + + it.each(actionKeys)( + "changes the current month when %s key is pressed and next button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const nextButton = wrapper.find(StyledButton).at(1); + (nextButton.getDOMNode() as HTMLElement).focus(); + nextButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + } + ); }); describe("Navbar Button", () => { diff --git a/src/components/date/date-test.stories.tsx b/src/components/date/date-test.stories.tsx index 5c14dd696f..2dee56f5de 100644 --- a/src/components/date/date-test.stories.tsx +++ b/src/components/date/date-test.stories.tsx @@ -91,9 +91,12 @@ NewValidationStory.args = { export const DateInputCustom = ({ onChange, onBlur, + value, ...props }: Partial & Partial) => { - const [state, setState] = React.useState("01/05/2022"); + const [state, setState] = React.useState( + value?.length !== undefined ? value : "01/05/2022" + ); const handleOnChange = (ev: DateChangeEvent) => { if (onChange) { diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index ae7fd4b7e6..206303ec99 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -29,6 +29,7 @@ import DateRangeContext, { InputName } from "../date-range/date-range.context"; import useClickAwayListener from "../../hooks/__internal__/useClickAwayListener"; import Logger from "../../__internal__/utils/logger"; import useFormSpacing from "../../hooks/__internal__/useFormSpacing"; +import guid from "../../__internal__/utils/helpers/guid"; interface CustomDateEvent { type: string; @@ -153,6 +154,7 @@ export const DateInput = React.forwardRef( : parseDate(format, value) ); const isInitialValue = useRef(true); + const pickerTabGuardId = useRef(guid()); if (!deprecateInputRefWarnTriggered && inputRef) { deprecateInputRefWarnTriggered = true; @@ -306,8 +308,19 @@ export const DateInput = React.forwardRef( onKeyDown(ev); } - if (Events.isTabKey(ev)) { + if (Events.isEscKey(ev)) { setOpen(false); + } + + if (open && Events.isTabKey(ev)) { + if (Events.isShiftKey(ev)) { + setOpen(false); + } else if (!disablePortal) { + ev.preventDefault(); + (document?.querySelector( + `[id="${pickerTabGuardId.current}"]` + ) as HTMLElement)?.focus(); + } alreadyFocused.current = false; } }; @@ -488,6 +501,8 @@ export const DateInput = React.forwardRef( maxDate={maxDate} pickerMouseDown={handlePickerMouseDown} open={open} + setOpen={setOpen} + pickerTabGuardId={pickerTabGuardId.current} /> ); diff --git a/src/components/date/date.spec.tsx b/src/components/date/date.spec.tsx index b0cff6d43d..6e76402793 100644 --- a/src/components/date/date.spec.tsx +++ b/src/components/date/date.spec.tsx @@ -42,46 +42,51 @@ import { enUS as enUSLocale, } from "../../locales/date-fns-locales"; import Logger from "../../__internal__/utils/logger"; +import StyledButton from "./__internal__/navbar/button.style"; +const ariaLabels = { + nextMonthButton: () => "foo", + previousMonthButton: () => "foo", +}; const locales = { "en-GB": { locale: () => "en-GB", - date: { dateFnsLocale: () => enGBLocale }, + date: { ariaLabels, dateFnsLocale: () => enGBLocale }, separator: "/", }, de: { locale: () => "de", - date: { dateFnsLocale: () => deLocale }, + date: { ariaLabels, dateFnsLocale: () => deLocale }, separator: ".", }, es: { locale: () => "es", - date: { dateFnsLocale: () => esLocale }, + date: { ariaLabels, dateFnsLocale: () => esLocale }, separator: "/", }, "en-ZA": { locale: () => "en-ZA", - date: { dateFnsLocale: () => enZALocale }, + date: { ariaLabels, dateFnsLocale: () => enZALocale }, separator: "/", }, "fr-FR": { locale: () => "fr-FR", - date: { dateFnsLocale: () => frLocale }, + date: { ariaLabels, dateFnsLocale: () => frLocale }, separator: "/", }, "fr-CA": { locale: () => "fr-CA", - date: { dateFnsLocale: () => frCALocale }, + date: { ariaLabels, dateFnsLocale: () => frCALocale }, separator: "/", }, "en-US": { locale: () => "en-US", - date: { dateFnsLocale: () => enUSLocale }, + date: { ariaLabels, dateFnsLocale: () => enUSLocale }, separator: "/", }, "en-CA": { locale: () => "en-CA", - date: { dateFnsLocale: () => enCALocale }, + date: { ariaLabels, dateFnsLocale: () => enCALocale }, separator: "/", }, }; @@ -151,8 +156,12 @@ function simulateMouseDownOnPicker(wrapper: ReactWrapper) { }); } -function simulateOnKeyDown(wrapper: ReactWrapper, key: string) { - const keyDownParams = { key }; +function simulateOnKeyDown( + wrapper: ReactWrapper, + key: string, + shiftKey?: boolean +) { + const keyDownParams = { key, shiftKey }; const input = wrapper.find("input"); act(() => { @@ -515,20 +524,85 @@ describe("Date", () => { }); }); - describe('and with the "Tab" key', () => { - it('then the "DatePicker" should be closed', () => { - expect(wrapper.update().find(DayPicker).exists()).toBe(true); - simulateOnKeyDown(wrapper, "Tab"); - expect(wrapper.update().find(DayPicker).exists()).toBe(false); + it('the "DatePicker" should remain open and the previous month navigation button should be focused when the "Tab" key is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + }); + + it('the "DatePicker" should remain open and the previous month navigation button should be focused when the "Tab" key is pressed and disablePortal set', () => { + wrapper = render({ disablePortal: true }); + simulateFocusOnInput(wrapper); + + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + }); + + it('the "DatePicker" should close when the "Escape" key is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + act(() => { + simulateOnKeyDown(wrapper, "Escape"); }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); }); - describe('and with the key other that "Tab"', () => { - it('then the "DatePicker" should not be closed', () => { - expect(wrapper.update().find(DayPicker).exists()).toBe(true); - simulateOnKeyDown(wrapper, "Enter"); - expect(wrapper.update().find(DayPicker).exists()).toBe(true); + it('the "DatePicker" should close when the "Shift" and "Tab" keys are pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + act(() => { + simulateOnKeyDown(wrapper, "Tab", true); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it('the "DatePicker" should not be closed when a key other than "Tab" or "Escape" is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Enter"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + }); + }); + + describe('when the "keyDown" event is triggered on the picker', () => { + beforeEach(() => { + wrapper = render(); + simulateFocusOnInput(wrapper); + }); + + it('should close the picker when "Escape" key is pressed and focus is in picker', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + act(() => { + wrapper.find(StyledDayPicker).simulate("keydown", { key: "Escape" }); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it('should close the picker when "Shift" + "Tab" keys are pressed and focus is on previous month button', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + act(() => { + wrapper + .find(StyledDayPicker) + .simulate("keydown", { key: "Tab", shiftKey: true }); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it("should close the picker when Tab pressed and day element focused", () => { + const picker = wrapper.update().find(DayPicker); + expect(picker.exists()).toBe(true); + act(() => { + picker + ?.props() + ?.onDayKeyDown?.(new Date(), { today: false, outside: false }, { + key: "Tab", + preventDefault: () => {}, + } as React.KeyboardEvent); }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); }); }); diff --git a/src/components/date/date.stories.mdx b/src/components/date/date.stories.mdx index 58457af0d9..b3f84d556f 100644 --- a/src/components/date/date.stories.mdx +++ b/src/components/date/date.stories.mdx @@ -230,5 +230,19 @@ The following keys are available to override the translations for this component type: "func", returnType: "date-fns locale object", }, + { + name: "date.ariaLabels.previousMonthButton", + description: + "Aria label text for the previous month navigation button in the date picker", + type: "func", + returnType: "string", + }, + { + name: "date.ariaLabels.nextMonthButton", + description: + "Aria label text for the next month navigation button in the date picker", + type: "func", + returnType: "string", + } ]} /> diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index 849bf7fdf0..402dab35af 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -448,7 +448,13 @@ export const LocaleOverrideExampleImplementation: ComponentStory< "de-DE", - date: { dateFnsLocale: () => de }, + date: { + dateFnsLocale: () => de, + ariaLabels: { + previousMonthButton: () => "Vorheriger Monat", + nextMonthButton: () => "Nächster Monat", + }, + }, }} > "zh-CN", - date: { dateFnsLocale: () => zhCN }, + date: { + dateFnsLocale: () => zhCN, + ariaLabels: { + previousMonthButton: () => "上个月", + nextMonthButton: () => "下个月", + }, + }, }} > {stickyHeader && ( diff --git a/src/components/drawer/drawer.pw.tsx b/src/components/drawer/drawer.pw.tsx index 1982132083..5a5b770c06 100644 --- a/src/components/drawer/drawer.pw.tsx +++ b/src/components/drawer/drawer.pw.tsx @@ -17,6 +17,7 @@ import { checkAccessibility, assertCssValueIsApproximately, isInViewport, + waitForAnimationEnd, } from "../../../playwright/support/helper"; test.describe( @@ -361,6 +362,10 @@ test.describe("Accessibility tests for Drawer component", () => { ); + await waitForAnimationEnd( + page.locator('[data-element="drawer-content"]') + ); + const drawerToggleButton = drawerToggle(page); await drawerToggleButton.click(); const sidebar = drawerSidebar(page); diff --git a/src/components/duelling-picklist/components.test-pw.tsx b/src/components/duelling-picklist/components.test-pw.tsx new file mode 100644 index 0000000000..0252fc208d --- /dev/null +++ b/src/components/duelling-picklist/components.test-pw.tsx @@ -0,0 +1,724 @@ +import React, { useState, useCallback, useMemo } from "react"; +import { Checkbox } from "../checkbox/checkbox.component"; +import Button from "../button/button.component"; +import Dialog from "../dialog/dialog.component"; +import Typography from "../typography/typography.component"; +import Box from "../box/box.component"; +import { Default } from "./duelling-picklist-test.stories"; +import { + DuellingPicklist, + Picklist, + PicklistItem, + PicklistItemProps, + PicklistPlaceholder, + DuellingPicklistProps, + PicklistProps, + PicklistDivider, + PicklistGroup, + PicklistPlaceholderProps, +} from "."; +import Search from "../search"; + +type Item = { key: string; title: string; description: string }; +type AllItems = { [key: string]: Item }; + +export const DuellingPicklistComponent = ( + props: Partial< + DuellingPicklistProps & + PicklistProps & + PicklistPlaceholderProps & + PicklistItemProps + > +) => { + const mockData: Item[] = useMemo(() => { + const arr = []; + for (let i = 0; i < 10; i++) { + const data = { + key: i.toString(), + title: `Content ${i + 1}`, + description: `Description ${i + 1}`, + }; + arr.push(data); + } + return arr; + }, []); + + const allItems = useMemo(() => { + return mockData.reduce((obj, item) => { + obj[item.key] = item; + return obj; + }, {} as { [key: string]: Item }); + }, [mockData]); + + const [isEachItemSelected] = useState(false); + const [order] = useState(mockData.map(({ key }) => key)); + const [notSelectedItems, setNotSelectedItems] = useState(allItems); + const [notSelectedSearch, setNotSelectedSearch] = useState({}); + const [selectedItems, setSelectedItems] = useState({}); + const [searchQuery, setSearchQuery] = useState(""); + const isSearchMode = Boolean(searchQuery.length); + + const onAdd = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = notSelectedItems; + setNotSelectedItems(rest); + setSelectedItems({ ...selectedItems, [item.key]: item }); + const { [item.key]: removed2, ...rest2 } = notSelectedSearch; + setNotSelectedSearch(rest2); + }, + [notSelectedItems, notSelectedSearch, selectedItems] + ); + + const onRemove = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = selectedItems; + setSelectedItems(rest); + setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); + if (isSearchMode && item.title.includes(searchQuery)) { + setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); + } + }, + [ + isSearchMode, + notSelectedItems, + notSelectedSearch, + searchQuery, + selectedItems, + ] + ); + + const handleSearch = useCallback( + (ev) => { + setSearchQuery(ev.target.value); + const tempNotSelectedItems = Object.keys(notSelectedItems).reduce( + (items, key) => { + const item = notSelectedItems[key]; + if (item.title.includes(ev.target.value)) { + items[item.key] = item; + } + return items; + }, + {} as AllItems + ); + setNotSelectedSearch(tempNotSelectedItems); + }, + [notSelectedItems] + ); + + const renderItems = ( + list: AllItems, + type: PicklistItemProps["type"], + handler: PicklistItemProps["onChange"] + ) => + order.reduce((items, key) => { + const item = list[key]; + if (item) { + items.push( + +
+
+

+ {item.title} +

+
+
+

{item.description}

+
+
+
+ ); + } + return items; + }, [] as JSX.Element[]); + + return ( +
+ + } + rightControls={ + + } + {...props} + > + + } + {...props} + > + {renderItems( + isSearchMode ? notSelectedSearch : notSelectedItems, + "add", + onAdd + )} + + } + > + {renderItems(selectedItems, "remove", onRemove)} + + +
+ ); +}; + +export const DuellingPicklistComponentAssigned = ( + props: Partial< + DuellingPicklistProps & + PicklistProps & + PicklistPlaceholderProps & + PicklistItemProps + > +) => { + const mockData: Item[] = useMemo(() => { + const arr = []; + for (let i = 0; i < 10; i++) { + const data = { + key: i.toString(), + title: `Content ${i + 1}`, + description: `Description ${i + 1}`, + }; + arr.push(data); + } + return arr; + }, []); + + const allItems = useMemo(() => { + return mockData.reduce((obj, item) => { + obj[item.key] = item; + return obj; + }, {} as { [key: string]: Item }); + }, [mockData]); + + const [isEachItemSelected] = useState(false); + const [order] = useState(mockData.map(({ key }) => key)); + const [notSelectedItems, setNotSelectedItems] = useState({}); + const [notSelectedSearch, setNotSelectedSearch] = useState({}); + const [selectedItems, setSelectedItems] = useState(allItems); + const [searchQuery, setSearchQuery] = useState(""); + const isSearchMode = Boolean(searchQuery.length); + + const onAdd = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = notSelectedItems; + setNotSelectedItems(rest); + setSelectedItems({ ...selectedItems, [item.key]: item }); + const { [item.key]: removed2, ...rest2 } = notSelectedSearch; + setNotSelectedSearch(rest2); + }, + [notSelectedItems, notSelectedSearch, selectedItems] + ); + + const onRemove = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = selectedItems; + setSelectedItems(rest); + setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); + if (isSearchMode && item.title.includes(searchQuery)) { + setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); + } + }, + [ + isSearchMode, + notSelectedItems, + notSelectedSearch, + searchQuery, + selectedItems, + ] + ); + + const handleSearch = useCallback( + (ev) => { + setSearchQuery(ev.target.value); + const tempNotSelectedItems = Object.keys(notSelectedItems).reduce( + (items, key) => { + const item = notSelectedItems[key]; + if (item.title.includes(ev.target.value)) { + items[item.key] = item; + } + return items; + }, + {} as AllItems + ); + setNotSelectedSearch(tempNotSelectedItems); + }, + [notSelectedItems] + ); + + const renderItems = ( + list: AllItems, + type: PicklistItemProps["type"], + handler: PicklistItemProps["onChange"] + ) => + order.reduce((items, key) => { + const item = list[key]; + if (item) { + items.push( + +
+
+

+ {item.title} +

+
+
+

{item.description}

+
+
+
+ ); + } + return items; + }, [] as JSX.Element[]); + + return ( +
+ + } + rightControls={ + + } + {...props} + > + + } + {...props} + > + {renderItems( + isSearchMode ? notSelectedSearch : notSelectedItems, + "add", + onAdd + )} + + } + > + {renderItems(selectedItems, "remove", onRemove)} + + +
+ ); +}; + +export const AlternativeSearch = () => { + const mockData = useMemo(() => { + const arr = []; + for (let i = 0; i < 20; i++) { + const data = { + key: i.toString(), + title: `Content ${i + 1}`, + description: `Description ${i + 1}`, + }; + arr.push(data); + } + return arr; + }, []); + + const allItems = useMemo(() => { + return mockData.reduce((obj, item) => { + obj[item.key] = item; + return obj; + }, {} as AllItems); + }, [mockData]); + + const [isEachItemSelected, setIsEachItemSelected] = useState(false); + const [order] = useState(mockData.map(({ key }) => key)); + const [notSelectedItems, setNotSelectedItems] = useState(allItems); + const [notSelectedSearch, setNotSelectedSearch] = useState({}); + const [selectedItems, setSelectedItems] = useState({}); + const [searchQuery, setSearchQuery] = useState(""); + const isSearchMode = Boolean(searchQuery.length); + + const onAdd = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = notSelectedItems; + setNotSelectedItems(rest); + setSelectedItems({ ...selectedItems, [item.key]: item }); + const { [item.key]: removed2, ...rest2 } = notSelectedSearch; + setNotSelectedSearch(rest2); + }, + [notSelectedItems, notSelectedSearch, selectedItems] + ); + + const onRemove = useCallback( + (item) => { + const { [item.key]: removed, ...rest } = selectedItems; + setSelectedItems(rest); + setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); + if (isSearchMode && item.title.includes(searchQuery)) { + setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); + } + }, + [ + isSearchMode, + notSelectedItems, + notSelectedSearch, + searchQuery, + selectedItems, + ] + ); + + const handleSearch = useCallback( + (ev) => { + setSearchQuery(ev.target.value); + const tempNotSelectedItems = Object.keys(notSelectedItems).reduce( + (items, key) => { + const item = notSelectedItems[key]; + if (item.title.includes(ev.target.value)) { + items[item.key] = item; + } + return items; + }, + {} as AllItems + ); + setNotSelectedSearch(tempNotSelectedItems); + }, + [notSelectedItems] + ); + + const renderItems = ( + list: AllItems, + type: PicklistItemProps["type"], + handler: PicklistItemProps["onChange"] + ) => + order.reduce((items, key) => { + const item = list[key]; + if (item) { + items.push( + +
+
+

+ {item.title} +

+
+
+

{item.description}

+
+
+
+ ); + } + return items; + }, [] as JSX.Element[]); + + return ( + <> + + setIsEachItemSelected(!isEachItemSelected)} + checked={isEachItemSelected} + label="Example checkbox" + /> + + + + + + Your own placeholder
} + > + {renderItems( + isSearchMode ? notSelectedSearch : notSelectedItems, + "add", + onAdd + )} + + } + > + {renderItems(selectedItems, "remove", onRemove)} + + + + ); +}; + +export const Grouped = () => { + const allGroups = { + groupA: "Group A", + groupB: "Group B", + groupC: "Group C", + }; + + const mockData = [ + { key: 1, title: "Content 1", group: "groupA" }, + { key: 2, title: "Content 2", group: "groupA" }, + { key: 3, title: "Content 3", group: "groupA" }, + { key: 4, title: "Content 4", group: "groupB" }, + { key: 5, title: "Content 5", group: "groupC" }, + { key: 6, title: "Content 6", group: "groupC" }, + ]; + + type MockData = typeof mockData; + type GroupKey = keyof typeof allGroups; + + const [notSelectedItems, setNotSelectedItems] = useState([ + ...mockData, + ]); + const [selectedItems, setSelectedItems] = useState([]); + + const onAdd = useCallback( + (item) => { + setSelectedItems([...selectedItems, item]); + setNotSelectedItems([ + ...notSelectedItems.filter((i) => i.key !== item.key), + ]); + }, + [notSelectedItems, selectedItems] + ); + + const onRemove = useCallback( + (item) => { + setNotSelectedItems([...notSelectedItems, item]); + setSelectedItems([...selectedItems.filter((i) => i.key !== item.key)]); + }, + [notSelectedItems, selectedItems] + ); + + const addGroup = useCallback( + (group: GroupKey) => { + const groupItems = notSelectedItems.filter( + (item) => item.group === group + ); + setNotSelectedItems([ + ...notSelectedItems.filter((item) => item.group !== group), + ]); + setSelectedItems([...selectedItems, ...groupItems]); + }, + [notSelectedItems, selectedItems] + ); + + const removeGroup = useCallback( + (group: GroupKey) => { + const groupItems = selectedItems.filter((item) => item.group === group); + + setSelectedItems([ + ...selectedItems.filter((item) => item.group !== group), + ]); + setNotSelectedItems([...notSelectedItems, ...groupItems]); + }, + [notSelectedItems, selectedItems] + ); + + const renderItems = ( + list: MockData, + type: PicklistItemProps["type"], + handler: PicklistItemProps["onChange"] + ) => { + if (!list) return null; + + list.sort((a, b) => a.key - b.key); + + return list.map((item) => { + return ( + +
+

+ {item.title} +

+
+
+ ); + }); + }; + + return ( + <> + + } + > + {Object.entries(allGroups).map(([key, value]) => { + const groupItems = notSelectedItems.filter( + (item) => item.group === key + ); + return groupItems.length ? ( + {value}} + type="add" + onChange={() => addGroup(key as GroupKey)} + > + {renderItems(groupItems, "add", onAdd)} + + ) : null; + })} + + + } + > + {Object.entries(allGroups).map(([key, value]) => { + const groupItems = selectedItems.filter( + (item) => item.group === key + ); + return groupItems.length ? ( + {value}} + type="remove" + onChange={() => removeGroup(key as GroupKey)} + > + {renderItems(groupItems, "remove", onRemove)} + + ) : null; + })} + + + + ); +}; + +export const InDialog = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + return ( + <> + + setIsDialogOpen(false)} + title="Duelling Picklist" + size="large" + > + + + + ); +}; + +export const AddItem = () => ( +
    + null}> +
    +
    +

    + Title for Item +

    +
    +
    +
    +
+); + +export const RemoveItem = () => ( +
    + null}> +
    +
    +

    + Title for Item +

    +
    +
    +
    +
+); + +export const Locked = () => ( +
    + null} locked> +
    +
    +

    + Title for Item +

    +
    +
    +
    +
+); + +export const CustomTooltipMessage = () => ( +
    + null} + locked + tooltipMessage="This is a custom locked tooltip message" + > +
    +
    +

    + Title for Item +

    +
    +
    +
    +
+); diff --git a/src/components/duelling-picklist/duelling-picklist-test.stories.tsx b/src/components/duelling-picklist/duelling-picklist-test.stories.tsx index d8c4f1576e..2375393d95 100644 --- a/src/components/duelling-picklist/duelling-picklist-test.stories.tsx +++ b/src/components/duelling-picklist/duelling-picklist-test.stories.tsx @@ -8,8 +8,6 @@ import { PicklistItemProps, PicklistDivider, PicklistPlaceholder, - DuellingPicklistProps, - PicklistProps, } from "."; import Search from "../search"; import { Checkbox } from "../checkbox"; @@ -175,396 +173,3 @@ export const Default = () => { }; Default.storyName = "default"; - -export const DuellingPicklistComponent = ( - props: Partial -) => { - const mockData: Item[] = useMemo(() => { - const arr = []; - for (let i = 0; i < 10; i++) { - const data = { - key: i.toString(), - title: `Content ${i + 1}`, - description: `Description ${i + 1}`, - }; - arr.push(data); - } - return arr; - }, []); - - const allItems = useMemo(() => { - return mockData.reduce((obj, item) => { - obj[item.key] = item; - return obj; - }, {} as { [key: string]: Item }); - }, [mockData]); - - const [isEachItemSelected] = useState(false); - const [order] = useState(mockData.map(({ key }) => key)); - const [notSelectedItems, setNotSelectedItems] = useState(allItems); - const [notSelectedSearch, setNotSelectedSearch] = useState({}); - const [selectedItems, setSelectedItems] = useState({}); - const [searchQuery, setSearchQuery] = useState(""); - const isSearchMode = Boolean(searchQuery.length); - - const onAdd = useCallback( - (item) => { - const { [item.key]: removed, ...rest } = notSelectedItems; - setNotSelectedItems(rest); - setSelectedItems({ ...selectedItems, [item.key]: item }); - const { [item.key]: removed2, ...rest2 } = notSelectedSearch; - setNotSelectedSearch(rest2); - }, - [notSelectedItems, notSelectedSearch, selectedItems] - ); - - const onRemove = useCallback( - (item) => { - const { [item.key]: removed, ...rest } = selectedItems; - setSelectedItems(rest); - setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); - if (isSearchMode && item.title.includes(searchQuery)) { - setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); - } - }, - [ - isSearchMode, - notSelectedItems, - notSelectedSearch, - searchQuery, - selectedItems, - ] - ); - - const handleSearch = useCallback( - (ev) => { - setSearchQuery(ev.target.value); - const tempNotSelectedItems = Object.keys(notSelectedItems).reduce( - (items, key) => { - const item = notSelectedItems[key]; - if (item.title.includes(ev.target.value)) { - items[item.key] = item; - } - return items; - }, - {} as AllItems - ); - setNotSelectedSearch(tempNotSelectedItems); - }, - [notSelectedItems] - ); - - const renderItems = ( - list: AllItems, - type: PicklistItemProps["type"], - handler: PicklistItemProps["onChange"] - ) => - order.reduce((items, key) => { - const item = list[key]; - if (item) { - items.push( - -
-
-

- {item.title} -

-
-
-

{item.description}

-
-
-
- ); - } - return items; - }, [] as JSX.Element[]); - - return ( -
- - } - rightControls={ - - } - {...props} - > - } - > - {renderItems( - isSearchMode ? notSelectedSearch : notSelectedItems, - "add", - onAdd - )} - - } - > - {renderItems(selectedItems, "remove", onRemove)} - - -
- ); -}; - -export const DuellingPicklistComponentPicklistItemProps = ( - props: Partial -) => { - const mockData: Item[] = useMemo(() => { - const arr = []; - for (let i = 0; i < 10; i++) { - const data = { - key: i.toString(), - title: `Content ${i + 1}`, - description: `Description ${i + 1}`, - }; - arr.push(data); - } - return arr; - }, []); - - const allItems = useMemo(() => { - return mockData.reduce((obj, item) => { - obj[item.key] = item; - return obj; - }, {} as { [key: string]: Item }); - }, [mockData]); - - const [isEachItemSelected] = useState(false); - const [order] = useState(mockData.map(({ key }) => key)); - const [notSelectedItems, setNotSelectedItems] = useState(allItems); - const [notSelectedSearch, setNotSelectedSearch] = useState({}); - const [selectedItems, setSelectedItems] = useState({}); - const [searchQuery] = useState(""); - const isSearchMode = Boolean(searchQuery.length); - - const onAdd = useCallback( - (item) => { - const { [item.key]: removed, ...rest } = notSelectedItems; - setNotSelectedItems(rest); - setSelectedItems({ ...selectedItems, [item.key]: item }); - const { [item.key]: removed2, ...rest2 } = notSelectedSearch; - setNotSelectedSearch(rest2); - }, - [notSelectedItems, notSelectedSearch, selectedItems] - ); - - const onRemove = React.useCallback( - (item) => { - const { [item.key]: removed, ...rest } = selectedItems; - setSelectedItems(rest); - setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); - if (isSearchMode && item.title.includes(searchQuery)) { - setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); - } - }, - [ - isSearchMode, - notSelectedItems, - notSelectedSearch, - searchQuery, - selectedItems, - ] - ); - const renderItems = ( - list: AllItems, - type: PicklistItemProps["type"], - handler: PicklistItemProps["onChange"] - ) => - order.reduce((items, key) => { - const item = list[key]; - if (item) { - items.push( - -
-
-

- {item.title} -

-
-
-

{item.description}

-
-
-
- ); - } - return items; - }, [] as JSX.Element[]); - return ( -
- - } - {...props} - > - {renderItems( - isSearchMode ? notSelectedSearch : notSelectedItems, - "add", - onAdd - )} - - } - > - {renderItems( - isSearchMode ? notSelectedSearch : notSelectedItems, - "remove", - onRemove - )} - - -
- ); -}; - -export const DuellingPicklistComponentPicklistProps = ( - props: Partial -) => { - const mockData: Item[] = useMemo(() => { - const arr = []; - for (let i = 0; i < 10; i++) { - const data = { - key: i.toString(), - title: `Content ${i + 1}`, - description: `Description ${i + 1}`, - }; - arr.push(data); - } - return arr; - }, []); - - const allItems = useMemo(() => { - return mockData.reduce((obj, item) => { - obj[item.key] = item; - return obj; - }, {} as { [key: string]: Item }); - }, [mockData]); - - const [isEachItemSelected] = useState(false); - const [order] = useState(mockData.map(({ key }) => key)); - const [notSelectedItems, setNotSelectedItems] = useState(allItems); - const [notSelectedSearch, setNotSelectedSearch] = useState({}); - const [selectedItems, setSelectedItems] = useState({}); - const [searchQuery] = useState(""); - const isSearchMode = Boolean(searchQuery.length); - - const onAdd = useCallback( - (item) => { - const { [item.key]: removed, ...rest } = notSelectedItems; - setNotSelectedItems(rest); - setSelectedItems({ ...selectedItems, [item.key]: item }); - const { [item.key]: removed2, ...rest2 } = notSelectedSearch; - setNotSelectedSearch(rest2); - }, - [notSelectedItems, notSelectedSearch, selectedItems] - ); - - const onRemove = React.useCallback( - (item) => { - const { [item.key]: removed, ...rest } = selectedItems; - setSelectedItems(rest); - setNotSelectedItems({ ...notSelectedItems, [item.key]: item }); - if (isSearchMode && item.title.includes(searchQuery)) { - setNotSelectedSearch({ ...notSelectedSearch, [item.key]: item }); - } - }, - [ - isSearchMode, - notSelectedItems, - notSelectedSearch, - searchQuery, - selectedItems, - ] - ); - const renderItems = ( - list: AllItems, - type: PicklistItemProps["type"], - handler: PicklistItemProps["onChange"] - ) => - order.reduce((items, key) => { - const item = list[key]; - if (item) { - items.push( - -
-
-

- {item.title} -

-
-
-

{item.description}

-
-
-
- ); - } - return items; - }, [] as JSX.Element[]); - return ( -
- - } - {...props} - > - {renderItems( - isSearchMode ? notSelectedSearch : notSelectedItems, - "add", - onAdd - )} - - } - > - {renderItems( - isSearchMode ? notSelectedSearch : notSelectedItems, - "remove", - onRemove - )} - - -
- ); -}; diff --git a/src/components/duelling-picklist/duelling-picklist.pw.tsx b/src/components/duelling-picklist/duelling-picklist.pw.tsx new file mode 100644 index 0000000000..75c389a315 --- /dev/null +++ b/src/components/duelling-picklist/duelling-picklist.pw.tsx @@ -0,0 +1,743 @@ +import React from "react"; +import { test, expect } from "@playwright/experimental-ct-react17"; +import { PicklistItemProps } from "./picklist-item/picklist-item.component"; +import { + DuellingPicklistComponent, + DuellingPicklistComponentAssigned, + AlternativeSearch, + Grouped, + InDialog, + AddItem, + RemoveItem, + Locked, + CustomTooltipMessage, +} from "./components.test-pw"; +import { checkAccessibility } from "../../../playwright/support/helper"; +import { + getComponent, + getDataElementByValue, + tooltipPreview, +} from "../../../playwright/components"; +import { CHARACTERS } from "../../../playwright/support/constants"; +import { ICON } from "../../../playwright/components/locators"; +import { DuellingPicklistProps } from "./duelling-picklist.component"; + +const specialCharacters = [ + CHARACTERS.STANDARD, + CHARACTERS.DIACRITICS, + CHARACTERS.SPECIALCHARACTERS, +]; +const keyToTrigger = ["Space", "Enter"] as const; + +test.describe(`should render Duelling-Picklist component`, () => { + test(`should verify unassigned picklist has 10 items`, async ({ + mount, + page, + }) => { + await mount(); + + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(10); + await expect(getDataElementByValue(page, "picklist-left-label")).toHaveText( + "List 1 (10)" + ); + }); + + test(`should verify assigned picklist has 0 items`, async ({ + mount, + page, + }) => { + await mount(); + + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .filter({ hasText: "Content" }) + ).toHaveCount(0); + await expect( + getDataElementByValue(page, "picklist-placeholder") + ).toHaveText("Nothing to see here"); + await expect( + getDataElementByValue(page, "picklist-right-label").nth(0) + ).toHaveText("List 2 (0)"); + }); + + test(`should verify component is enabled by default`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(getComponent(page, "duelling-picklist")).not.toHaveAttribute( + "disabled", + /.*/ + ); + }); + + [ + [1, 9], + [7, 3], + ].forEach(([items, leftItems]) => { + test(`should verify when ${items} item(s) are assigned that unassigned picklist has ${leftItems} items and assigned picklist has ${items} item(s)`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + for (let i = 0; i < items; i++) { + await addItemButton.click(); + } + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(leftItems); + await expect( + getDataElementByValue(page, "picklist-left-label").nth(0) + ).toHaveText(`List 1 (${leftItems})`); + await expect( + getDataElementByValue(page, "picklist").nth(1).locator("li") + ).toHaveCount(items); + await expect( + getDataElementByValue(page, "picklist-right-label").nth(0) + ).toHaveText(`List 2 (${items})`); + }); + }); + + test(`should verify assigned picklist has 10 items when all items are added`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + for (let i = 0; i < 10; i++) { + await addItemButton.click(); + } + await expect( + getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .filter({ hasText: "Content" }) + ).toHaveCount(0); + await expect( + getDataElementByValue(page, "picklist-placeholder") + ).toHaveText("Unassigned list empty"); + await expect( + getDataElementByValue(page, "picklist-left-label").nth(0) + ).toHaveText(`List 1 (0)`); + await expect( + getDataElementByValue(page, "picklist").nth(1).locator("li") + ).toHaveCount(10); + await expect( + getDataElementByValue(page, "picklist-right-label").nth(0) + ).toHaveText(`List 2 (10)`); + }); + + test(`should verify assigned picklist has 0 items when assigned item is removed`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + await addItemButton.click(); + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(9); + await expect( + getDataElementByValue(page, "picklist").nth(1).locator("li") + ).toHaveCount(1); + + const removeItemButton = getDataElementByValue(page, "remove"); + await removeItemButton.click(); + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(10); + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .filter({ hasText: "Content" }) + ).toHaveCount(0); + }); + + [...keyToTrigger].forEach((pressed) => { + test(`should verify item is added to assigned picklist when ${pressed} key is pressed`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + await addItemButton.press(pressed); + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(9); + await expect( + getDataElementByValue(page, "picklist").nth(1).locator("li") + ).toHaveCount(1); + }); + }); + + [...keyToTrigger].forEach((pressed) => { + test(`should verify item is removed from assigned picklist when ${pressed} key is pressed`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + for (let i = 0; i < 10; i++) { + await addItemButton.click(); + } + await expect( + getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .filter({ hasText: "Content" }) + ).toHaveCount(0); + await expect( + getDataElementByValue(page, "picklist").nth(1).locator("li") + ).toHaveCount(10); + + const removeItemButton = getDataElementByValue(page, "remove").first(); + for (let i = 0; i < 10; i++) { + await removeItemButton.press(pressed); + } + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(10); + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .filter({ hasText: "Content" }) + ).toHaveCount(0); + }); + }); + + ([ + ["Content", 10], + ["Content 1", 2], + ["Content 10", 1], + ] as [string, number][]).forEach(([searchString, results]) => { + test(`should verify when ${searchString} is enterted into search field that ${results} results are displayed`, async ({ + mount, + page, + }) => { + await mount(); + + const searchInput = page.getByLabel("search").first(); + await searchInput.fill(searchString); + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(results); + }); + }); + + test(`should verify leftControl prop in component generates search field appears above unassigned picklist`, async ({ + mount, + page, + }) => { + await mount(); + + const leftSearch = getDataElementByValue(page, "picklist-left-control") + .locator("div") + .nth(0); + await expect(leftSearch).toHaveAttribute("data-component", "search"); + }); + + test(`should verify rightControl prop in component generates search field appears above assigned picklist`, async ({ + mount, + page, + }) => { + await mount(); + + const rightSearch = getDataElementByValue(page, "picklist-right-label") + .locator("div") + .nth(0); + await expect(rightSearch).toHaveAttribute("data-component", "search"); + }); + + ([ + ["disabled", true], + ["enabled", false], + ] as [string, DuellingPicklistProps["disabled"]][]).forEach( + ([state, bool]) => { + test(`should verify Duelling-Picklist is ${state} when disabled prop is ${bool}`, async ({ + mount, + page, + }) => { + await mount(); + + if (bool) { + await expect(getComponent(page, "duelling-picklist")).toHaveAttribute( + "disabled", + /.*/ + ); + } else { + await expect( + getComponent(page, "duelling-picklist") + ).not.toHaveAttribute("disabled", /.*/); + } + }); + } + ); + + test(`should verify unassigned picklist label is 'Left Label'`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(getDataElementByValue(page, "picklist-left-label")).toHaveText( + "Left Label" + ); + }); + + test(`should verify assigned picklist label is 'Right Label'`, async ({ + mount, + page, + }) => { + await mount(); + + await expect( + getDataElementByValue(page, "picklist-right-label").nth(0) + ).toHaveText("Right Label"); + }); +}); + +test.describe(`should render Duelling-Picklist to test Picklist props`, () => { + [...specialCharacters].forEach((chars) => { + test(`should verify picklist placeholder is set to ${chars}`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + for (let i = 0; i < 10; i++) { + await addItemButton.click(); + } + + await expect( + getDataElementByValue(page, "picklist-placeholder") + ).toHaveText(chars); + }); + }); + + ([ + ["locked", true, "rgb(242, 245, 246)"], + ["unlocked", false, "rgb(255, 255, 255)"], + ] as [string, PicklistItemProps["locked"], string][]).forEach( + ([state, bool, backColor]) => { + test(`should verify picklist item is ${state} when locked prop is ${bool}`, async ({ + mount, + page, + }) => { + await mount(); + + const picklistItem = getDataElementByValue( + page, + "picklist-item" + ).first(); + await expect(picklistItem).toHaveCSS("background-color", backColor); + const picklistIcon = getDataElementByValue(page, "picklist-item") + .first() + .locator(ICON); + if (bool) { + await expect(picklistIcon).toHaveAttribute("data-element", state); + } else { + await expect(picklistIcon).not.toHaveAttribute("data-element", state); + } + }); + } + ); + + test(`should verify picklist tooltip is 'Item Locked' when locked prop is true`, async ({ + mount, + page, + }) => { + await mount( + + ); + + const listItemIcon = getDataElementByValue(page, "picklist-item") + .first() + .locator(ICON); + await listItemIcon.hover(); + await expect(tooltipPreview(page)).toHaveText("Item Locked"); + }); +}); + +test.describe( + `should render Duelling-Picklist with external searchbar and access checkbox`, + () => { + ([ + ["Content", 20], + ["Content 1", 11], + ["Content 10", 1], + ] as [string, number][]).forEach(([searchString, results]) => { + test(`should verify ${results} are found when search field is placed outside the component`, async ({ + mount, + page, + }) => { + await mount(); + + await page.getByLabel("search").fill(searchString); + await expect( + getDataElementByValue(page, "picklist").nth(0).locator("li") + ).toHaveCount(results); + }); + }); + + test(`should verify component is disabled when access checkox is checked`, async ({ + mount, + page, + }) => { + await mount(); + + const checkbox = page.getByRole("checkbox"); + await checkbox.check(); + await expect(getComponent(page, "duelling-picklist")).toHaveAttribute( + "disabled", + /.*/ + ); + }); + + test(`should verify component is re-enabled when access checkbox is unchecked`, async ({ + mount, + page, + }) => { + await mount(); + + const checkbox = page.getByRole("checkbox"); + await checkbox.check(); + await checkbox.uncheck(); + await expect(getComponent(page, "duelling-picklist")).not.toHaveAttribute( + "disabled", + /.*/ + ); + }); + } +); + +test.describe( + `should render Duelling-Picklist with items grouped and a picklist divider`, + () => { + test(`should verify component is displayed with divider`, async ({ + mount, + page, + }) => { + await mount(); + + await expect( + getDataElementByValue(page, "picklist-divider") + ).toBeAttached(); + }); + + test(`should verify component is displayed in groups with group label`, async ({ + mount, + page, + }) => { + await mount(); + + const group = getDataElementByValue(page, "picklist-group").first(); + await expect(group).toHaveText("Group A"); + }); + + test(`should verify all items in a group are added to assigned picklist when group add button is clicked`, async ({ + mount, + page, + }) => { + await mount(); + + const groupAddButton = getDataElementByValue(page, "picklist-group") + .first() + .locator("button"); + await groupAddButton.click(); + const group = getDataElementByValue(page, "picklist-group").nth(2); + await expect(group).toHaveText("Group A"); + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .locator("p") + ).toHaveCount(3); + }); + + test(`should verify all items in a group are removed from assigned picklist when group remove button is clicked`, async ({ + mount, + page, + }) => { + await mount(); + + const groupAddButton = getDataElementByValue(page, "picklist-group") + .first() + .locator("button"); + await groupAddButton.click(); + await expect( + getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .locator("p") + ).toHaveCount(3); + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .locator("p") + ).toHaveCount(3); + const groupRemoveButton = getDataElementByValue(page, "picklist-group") + .nth(2) + .locator("button"); + await groupRemoveButton.click(); + await expect( + getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .locator("p") + ).toHaveCount(6); + await expect( + getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .locator("p") + ).toHaveCount(0); + }); + } +); + +test.describe(`check events for Duelling-Picklist component`, () => { + test(`should call onChange when add button clicked`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + const addItemButton = page.getByRole("button").first(); + await addItemButton.click(); + expect(callbackCount).toBe(1); + }); + + test(`should call onChange when remove button clicked`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + const removeItemButton = page.getByRole("button").first(); + await removeItemButton.click(); + expect(callbackCount).toBe(1); + }); + + [...keyToTrigger].forEach((pressed) => { + test(`should call onChange when ${pressed} key pressed on add button`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + const addItemButton = page.getByRole("button").first(); + await addItemButton.press(pressed); + expect(callbackCount).toBe(1); + }); + }); + + [...keyToTrigger].forEach((pressed) => { + test(`should call onChange when ${pressed} key pressed on remove button`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + const removeItemButton = page.getByRole("button").first(); + await removeItemButton.press(pressed); + expect(callbackCount).toBe(1); + }); + }); +}); + +test.describe(`Accessibility tests for Duelling-Picklist component`, () => { + test(`should pass accessibility tests for default example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for AlternativeSearch example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for Grouped example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for InDialog example`, async ({ + mount, + page, + }) => { + await mount(); + + const dialogButton = getDataElementByValue(page, "main-text"); + await dialogButton.click(); + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for AddItem example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for RemoveItem example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for Locked example`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests for CustomTooltipMessage example`, async ({ + mount, + page, + }) => { + await mount(); + + const lockedItem = getDataElementByValue(page, "locked").first(); + await lockedItem.hover({ force: true }); + await expect(tooltipPreview(page)).toBeVisible(); + await checkAccessibility(page); + }); + + test.skip(`should pass accessibility tests when disabled`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); +}); + +test.describe("Border radius tests", () => { + test(`should render the items with the expected border radius styling`, async ({ + mount, + page, + }) => { + await mount(); + + const addItemButton = page.getByRole("button").first(); + for (let i = 0; i < 5; i++) { + await addItemButton.click(); + } + + const assignedItem1 = getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .nth(0); + const assignedItem2 = getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .nth(1); + const assignedItem3 = getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .nth(2); + const assignedItem4 = getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .nth(3); + const assignedItem5 = getDataElementByValue(page, "picklist") + .nth(0) + .locator("li") + .nth(4); + await expect(assignedItem1).toHaveCSS("border-radius", "8px"); + await expect(assignedItem2).toHaveCSS("border-radius", "8px"); + await expect(assignedItem3).toHaveCSS("border-radius", "8px"); + await expect(assignedItem4).toHaveCSS("border-radius", "8px"); + await expect(assignedItem5).toHaveCSS("border-radius", "8px"); + + const unassignedItem1 = getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .nth(0); + const unassignedItem2 = getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .nth(1); + const unassignedItem3 = getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .nth(2); + const unassignedItem4 = getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .nth(3); + const unassignedItem5 = getDataElementByValue(page, "picklist") + .nth(1) + .locator("li") + .nth(4); + await expect(unassignedItem1).toHaveCSS("border-radius", "8px"); + await expect(unassignedItem2).toHaveCSS("border-radius", "8px"); + await expect(unassignedItem3).toHaveCSS("border-radius", "8px"); + await expect(unassignedItem4).toHaveCSS("border-radius", "8px"); + await expect(unassignedItem5).toHaveCSS("border-radius", "8px"); + }); +}); diff --git a/src/components/flat-table/flat-table-test.stories.tsx b/src/components/flat-table/flat-table-test.stories.tsx index 30f1304b15..92518beab2 100644 --- a/src/components/flat-table/flat-table-test.stories.tsx +++ b/src/components/flat-table/flat-table-test.stories.tsx @@ -893,7 +893,7 @@ export const FlatTableCellRowSpanComponent = ( ); }; -export const FlatTableMutipleStickyComponent = ( +export const FlatTableMultipleStickyComponent = ( props: Partial ) => { return ( diff --git a/src/components/global-header/global-header-test.stories.tsx b/src/components/global-header/global-header-test.stories.tsx index a684e6955b..bcff7fd3a5 100644 --- a/src/components/global-header/global-header-test.stories.tsx +++ b/src/components/global-header/global-header-test.stories.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { ComponentMeta, ComponentStory } from "@storybook/react"; -import GlobalHeader from "./global-header.component"; +import GlobalHeader, { GlobalHeaderProps } from "./global-header.component"; import { Menu, MenuItem, MenuDivider } from "../menu"; import VerticalDivider from "../vertical-divider"; import NavigationBar from "../navigation-bar"; @@ -96,3 +96,23 @@ export const FullMenuExample = () => ( ); + +export const GlobalHeaderWithErrorHandler = ({ + ...props +}: GlobalHeaderProps) => { + const [error, setError] = useState(""); + useEffect(() => { + const handleError = (e: ErrorEvent) => { + setError(e.message); + }; + window.addEventListener("error", handleError); + + return () => window.removeEventListener("error", handleError); + }); + return ( + <> + +
{error}
+ + ); +}; diff --git a/src/components/global-header/global-header.spec.tsx b/src/components/global-header/global-header.spec.tsx index 060465d45f..6b5e2839e7 100644 --- a/src/components/global-header/global-header.spec.tsx +++ b/src/components/global-header/global-header.spec.tsx @@ -1,24 +1,12 @@ import { render, screen } from "@testing-library/react"; import React from "react"; import GlobalHeader, { GlobalHeaderProps } from "./global-header.component"; -import Logger from "../../__internal__/utils/logger"; - -// mock Logger.deprecate so that no console warnings occur while running the tests -const loggerSpy = jest.spyOn(Logger, "deprecate"); function renderer(props?: GlobalHeaderProps) { return render(foobar); } describe("Global Header", () => { - beforeAll(() => { - loggerSpy.mockImplementation(() => {}); - }); - - afterAll(() => { - loggerSpy.mockRestore(); - }); - it("should be visible with correct accessible name", () => { renderer(); expect(screen.getByRole("navigation")).toHaveAccessibleName( diff --git a/src/components/icon-button/component.test-pw.tsx b/src/components/icon-button/component.test-pw.tsx new file mode 100644 index 0000000000..18683a59d4 --- /dev/null +++ b/src/components/icon-button/component.test-pw.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import IconButton, { IconButtonProps } from "."; +import Icon from "../icon"; + +const IconButtonComponent = (props: Partial) => { + return ( + {}} {...props}> + + + ); +}; + +export default IconButtonComponent; diff --git a/src/components/icon-button/icon-button-test.stories.tsx b/src/components/icon-button/icon-button-test.stories.tsx index c0ef019e97..1852d0d483 100644 --- a/src/components/icon-button/icon-button-test.stories.tsx +++ b/src/components/icon-button/icon-button-test.stories.tsx @@ -22,11 +22,3 @@ export const Default = (props: IconButtonProps) => { }; Default.storyName = "default"; - -export const IconButtonComponent = (props: Partial) => { - return ( - {}} {...props}> - - - ); -}; diff --git a/src/components/icon-button/icon-button.pw.tsx b/src/components/icon-button/icon-button.pw.tsx new file mode 100644 index 0000000000..e32dd4b970 --- /dev/null +++ b/src/components/icon-button/icon-button.pw.tsx @@ -0,0 +1,215 @@ +import React from "react"; +import { test, expect } from "@playwright/experimental-ct-react17"; +import IconButtonComponent from "./component.test-pw"; +import { button as iconButton } from "../../../playwright/components/index"; +import { CHARACTERS } from "../../../playwright/support/constants"; +import { HooksConfig } from "../../../playwright"; +import { checkAccessibility } from "../../../playwright/support/helper"; + +test.describe( + "check IconButton component focus outlines and border radius", + () => { + test(`should have the expected styling when the focusRedesignOptOut is false`, async ({ + mount, + page, + }) => { + await mount(); + + await iconButton(page).focus(); + await expect(iconButton(page)).toHaveCSS( + "box-shadow", + "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px" + ); + + await expect(iconButton(page)).toHaveCSS( + "outline", + "rgba(0, 0, 0, 0) solid 3px" + ); + }); + + test(`should have the expected styling when the focusRedesignOptOut is true`, async ({ + mount, + page, + }) => { + await mount(, { + hooksConfig: { focusRedesignOptOut: true }, + }); + + await iconButton(page).focus(); + await expect(iconButton(page)).toHaveCSS( + "outline", + "rgb(255, 188, 25) solid 3px" + ); + }); + + test(`should render with the expected border radius`, async ({ + mount, + page, + }) => { + await mount(); + + await iconButton(page).focus(); + await expect(iconButton(page)).toHaveCSS("border-radius", "4px"); + }); + } +); + +test.describe("check props for IconButton component", () => { + test(`should render with aria-label prop`, async ({ mount, page }) => { + await mount(); + + await expect(iconButton(page)).toHaveAttribute( + "aria-label", + CHARACTERS.STANDARD + ); + await expect(iconButton(page)).toBeVisible(); + }); + + test(`should render with a child`, async ({ mount, page }) => { + await mount(); + + await expect(iconButton(page)).toBeVisible(); + }); + + test(`should render with disabled prop`, async ({ mount, page }) => { + await mount(); + + await expect(iconButton(page)).toBeDisabled(); + }); +}); + +test.describe("check events for IconButton component", () => { + test(`should call onBlur callback when a blur event is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await iconButton(page).focus(); + await iconButton(page).blur(); + expect(callbackCount).toBe(1); + }); + + test(`should call onFocus callback when a focus event is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await iconButton(page).focus(); + expect(callbackCount).toBe(1); + }); + + test(`should call onMouseEnter callback when a mouseover event is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await iconButton(page).hover(); + expect(callbackCount).toBe(1); + }); + + test(`should call onMouseLeave callback when a mouseout event is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await iconButton(page).hover(); + await page.mouse.move(100, 0); + expect(callbackCount).toBe(1); + }); + + test(`should call onClick callback when a click event is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + await iconButton(page).click(); + expect(callbackCount).toBe(1); + }); + + ["Enter", "Space"].forEach((key) => { + test(`should call onClick callback when ${key} key is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await iconButton(page).press(key); + expect(callbackCount).toBe(1); + }); + }); +}); + +test.describe("check accessibility tests for IconButton component", () => { + test(`should pass accessibility tests when rendered with an aria-label`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests when rendered with a child`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test(`should pass accessibility tests when disabled`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); +}); diff --git a/src/components/loader-bar/loader-bar-test.stories.tsx b/src/components/loader-bar/loader-bar-test.stories.tsx index 094ec6d2cd..74de0db4d5 100644 --- a/src/components/loader-bar/loader-bar-test.stories.tsx +++ b/src/components/loader-bar/loader-bar-test.stories.tsx @@ -1,10 +1,13 @@ import React from "react"; +import { ComponentStory } from "@storybook/react"; import LoaderBar, { LoaderBarProps } from "."; import { LOADER_BAR_SIZES } from "./loader-bar.config"; +import Box from "../box"; +import Typography from "../typography"; export default { title: "Loader Bar/Test", - includeStories: ["DefaultStory"], + includeStories: ["DefaultStory", "LoaderBarWithMinHeight"], parameters: { info: { disable: true }, chromatic: { @@ -30,3 +33,19 @@ DefaultStory.storyName = "default"; export const LoaderBarComponentTest = (props: LoaderBarProps) => { return ; }; + +export const LoaderBarWithMinHeight: ComponentStory = () => { + return ( + + + Small bar + + + + ); +}; + +LoaderBarWithMinHeight.parameters = { + chromatic: { disableSnapshot: false }, + controls: { disable: true }, +}; diff --git a/src/components/loader-bar/loader-bar.style.ts b/src/components/loader-bar/loader-bar.style.ts index 67a29559c0..1f94e34d2d 100644 --- a/src/components/loader-bar/loader-bar.style.ts +++ b/src/components/loader-bar/loader-bar.style.ts @@ -36,7 +36,6 @@ const innerBarAnimation = keyframes` const StyledLoaderBar = styled.div` ${({ size }) => css` - display: inline-block; border-radius: var(--borderRadius400); height: ${getHeight(size)}; width: 100%; diff --git a/src/components/loader/loader.pw.tsx b/src/components/loader/loader.pw.tsx index 712b1b7703..2fb884d7cb 100644 --- a/src/components/loader/loader.pw.tsx +++ b/src/components/loader/loader.pw.tsx @@ -182,7 +182,7 @@ test.describe("check props for Loader component test", () => { }); test.describe("Accessibility tests for Loader component", async () => { - test("should pass accessibilty tests for Loader default story", async ({ + test("should pass accessibility tests for Loader default story", async ({ mount, page, }) => { diff --git a/src/components/menu/menu.spec.tsx b/src/components/menu/menu.spec.tsx index b79d88ef8b..f14378756a 100644 --- a/src/components/menu/menu.spec.tsx +++ b/src/components/menu/menu.spec.tsx @@ -197,7 +197,7 @@ describe("Menu", () => { }); describe("with multiple submenus", () => { - it("when a sumenu is opened, any previously open submenu is closed", () => { + it("when a submenu is opened, any previously open submenu is closed", () => { wrapper = mount( menu item diff --git a/src/components/message/message.component.tsx b/src/components/message/message.component.tsx index 71b3b2a172..456529a8a6 100644 --- a/src/components/message/message.component.tsx +++ b/src/components/message/message.component.tsx @@ -87,7 +87,6 @@ export const Message = React.forwardRef( className={className} transparent={transparent} variant={variant} - role="status" id={id} ref={refToPass} {...marginProps} diff --git a/src/components/message/message.stories.mdx b/src/components/message/message.stories.mdx index e52c74c046..1d508f4abb 100644 --- a/src/components/message/message.stories.mdx +++ b/src/components/message/message.stories.mdx @@ -35,9 +35,10 @@ Useful for messages which are longer or more important, where the user needs tim Various types are available: - **Error** - tells the user about a negative outcome that has already happened. Try to focus the message text on the action the user needs to take to be successful, rather than what went wrong. -- **Info** - gives context or advice to the user where there’s no risk of a negative outcome. +- **Info** - gives advice to the user where there’s no risk of a negative outcome. - **Success** - indicates that an activity was successful. A good example could also present the user with onward options, such as ‘View a list of items’ or ‘Create another’. - **Warning** - warns the user about a potential negative outcome that hasn’t happened yet. +- **Neutral** - gives context to the user where there’s no risk of a negative outcome. - The Transparent configuration is useful if you’d like the message to be more visually subtle, perhaps in a Dialog. ## Related Components diff --git a/src/components/navigation-bar/components.test-pw.tsx b/src/components/navigation-bar/components.test-pw.tsx index e8aa9dd791..505a54f963 100644 --- a/src/components/navigation-bar/components.test-pw.tsx +++ b/src/components/navigation-bar/components.test-pw.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { ComponentStory } from "@storybook/react"; import NavigationBar, { NavigationBarProps } from "."; import { Menu, MenuDivider, MenuItem } from "../menu"; @@ -218,3 +218,23 @@ Fixed.parameters = { docs: { inlineStories: false, iframeHeight: 200 }, themeProvider: { chromatic: { theme: "sage" } }, }; + +export const NavigationBarWithErrorHandler = ({ + ...props +}: NavigationBarProps) => { + const [error, setError] = useState(""); + useEffect(() => { + const handleError = (e: ErrorEvent) => { + setError(e.message); + }; + window.addEventListener("error", handleError); + + return () => window.removeEventListener("error", handleError); + }); + return ( + <> + +
{error}
+ + ); +}; diff --git a/src/components/navigation-bar/fixed-navigation-bar-context.spec.tsx b/src/components/navigation-bar/fixed-navigation-bar-context.spec.tsx index 78842c449d..5ae4e9194b 100644 --- a/src/components/navigation-bar/fixed-navigation-bar-context.spec.tsx +++ b/src/components/navigation-bar/fixed-navigation-bar-context.spec.tsx @@ -14,15 +14,13 @@ const ConsumerComponent = () => { }; const mockNavbarElement = { offsetHeight: 40 } as HTMLElement; +const navbarRef = { current: mockNavbarElement }; const MockComponent = ( - props: Omit + props: Omit ) => { return ( - + ); diff --git a/src/components/navigation-bar/fixed-navigation-bar.context.tsx b/src/components/navigation-bar/fixed-navigation-bar.context.tsx index 6f62931f00..d47c3d60b9 100644 --- a/src/components/navigation-bar/fixed-navigation-bar.context.tsx +++ b/src/components/navigation-bar/fixed-navigation-bar.context.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useState, useCallback } from "react"; +import React, { createContext, useState, useCallback, useEffect } from "react"; import useResizeObserver from "../../hooks/__internal__/useResizeObserver/useResizeObserver"; import { NavigationBarProps } from "."; @@ -15,7 +15,7 @@ export interface FixedNavigationBarContextProviderProps NavigationBarProps, "position" | "orientation" | "offset" | "children" > { - navbarElement: HTMLElement | null; + navbarRef: React.RefObject; } export const FixedNavigationBarContextProvider = ({ @@ -23,16 +23,22 @@ export const FixedNavigationBarContextProvider = ({ orientation, offset, children, - navbarElement, + navbarRef, }: FixedNavigationBarContextProviderProps) => { - const [navbarHeight, setNavbarHeight] = useState(navbarElement?.offsetHeight); + const [navbarHeight, setNavbarHeight] = useState( + navbarRef.current?.offsetHeight + ); const updateHeight = useCallback( - () => setNavbarHeight(navbarElement?.offsetHeight), - [navbarElement] + () => setNavbarHeight(navbarRef.current?.offsetHeight), + [navbarRef] ); - useResizeObserver({ current: navbarElement }, updateHeight); + useEffect(() => { + updateHeight(); + }, [updateHeight]); + + useResizeObserver(navbarRef, updateHeight); let submenuMaxHeight; diff --git a/src/components/navigation-bar/navigation-bar-test.stories.tsx b/src/components/navigation-bar/navigation-bar-test.stories.tsx index 4060dfb53e..912e043816 100644 --- a/src/components/navigation-bar/navigation-bar-test.stories.tsx +++ b/src/components/navigation-bar/navigation-bar-test.stories.tsx @@ -1,9 +1,10 @@ -import React from "react"; +import React, { useRef } from "react"; import NavigationBar, { NavigationBarProps } from "."; +import { Menu, MenuItem } from "../menu"; export default { title: "Navigation Bar/Test", - includeStories: ["DefaultStory"], + includeStories: ["DefaultStory", "NavigationBarWithSubmenuAndChangingHeight"], parameters: { info: { disable: true }, chromatic: { @@ -25,3 +26,45 @@ DefaultStory.args = { position: undefined, offset: "0", }; + +export const NavigationBarWithSubmenuAndChangingHeight = () => { + const wrapperRef = useRef(null); + const toggleHeight = () => { + const navbarElement = wrapperRef.current?.querySelector("nav"); + if (navbarElement) { + navbarElement.style.height = + navbarElement.style.height === "100px" ? "40px" : "100px"; + } + }; + return ( +
+ + + + {}}>Foo 1 + {}}>Foo 2 + {}}>Foo 3 + Change Height! + {}}>Foo 4 + {}}>Foo 5 + {}}>Foo 6 + {}}>Foo 7 + {}}>Foo 8 + {}}>Foo 9 + {}}>Foo 10 + {}}>Foo 11 + {}}>Foo 12 + {}}>Foo 13 + {}}>Foo 14 + {}}>Foo 15 + {}}>Foo 16 + {}}>Foo 17 + {}}>Foo 18 + {}}>Foo 19 + {}}>Foo 20 + + + +
+ ); +}; diff --git a/src/components/navigation-bar/navigation-bar.component.tsx b/src/components/navigation-bar/navigation-bar.component.tsx index 86397ce734..9a795441cc 100644 --- a/src/components/navigation-bar/navigation-bar.component.tsx +++ b/src/components/navigation-bar/navigation-bar.component.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useRef } from "react"; import { PaddingProps, FlexboxProps } from "styled-system"; import StyledNavigationBar from "./navigation-bar.style"; import { FixedNavigationBarContextProvider } from "./fixed-navigation-bar.context"; @@ -32,35 +32,35 @@ export const NavigationBar = ({ children, ariaLabel, position, - offset = "0", + offset = "0px", orientation, isGlobal, ...props }: NavigationBarProps): JSX.Element => { - const [navbarElement, setNavbarElement] = useState(null); + const navbarRef = useRef(null); return ( - - {!isLoading && children} - - + +
); }; diff --git a/src/components/navigation-bar/navigation-bar.pw.tsx b/src/components/navigation-bar/navigation-bar.pw.tsx index 65c7513314..7724f16a68 100644 --- a/src/components/navigation-bar/navigation-bar.pw.tsx +++ b/src/components/navigation-bar/navigation-bar.pw.tsx @@ -15,6 +15,7 @@ import { ContentMaxWidthBox, Sticky, Fixed, + NavigationBarWithErrorHandler, } from "./components.test-pw"; import navigationBar from "../../../playwright/components/navigation-bar"; @@ -32,6 +33,15 @@ const variants = [ const offsetVal = [25, 100, -100]; test.describe("Test props for NavigationBar component", () => { + test("should not cause a ResizeObserver-related error to occur", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.locator("#error-div")).toContainText(""); + }); + specialCharacters.forEach((childrenValue) => { test(`should render with ${childrenValue} as a children`, async ({ mount, diff --git a/src/components/navigation-bar/navigation-bar.spec.tsx b/src/components/navigation-bar/navigation-bar.spec.tsx index 79fcbc7243..02fd159dd5 100644 --- a/src/components/navigation-bar/navigation-bar.spec.tsx +++ b/src/components/navigation-bar/navigation-bar.spec.tsx @@ -52,7 +52,9 @@ describe("NavigationBar", () => {
); - expect(wrapper.prop("data-component")).toBe("navigation-bar"); + expect(wrapper.find(StyledNavigationBar).prop("data-component")).toBe( + "navigation-bar" + ); }); it("should provide ariaLabel correctly", () => { @@ -62,7 +64,9 @@ describe("NavigationBar", () => { ); - expect(wrapper.prop("aria-label")).toBe("my aria label"); + expect(wrapper.find(StyledNavigationBar).prop("aria-label")).toBe( + "my aria label" + ); }); it("should render `light` scheme as default", () => { @@ -72,7 +76,9 @@ describe("NavigationBar", () => { ); - expect(wrapper.props().navigationType).toBe("light"); + expect(wrapper.find(StyledNavigationBar).props().navigationType).toBe( + "light" + ); }); it("should render correct styles in `light` scheme", () => { @@ -190,7 +196,7 @@ describe("NavigationBar", () => { assertStyleMatch( { position: `${position}`, - [orientation]: offset || "0", + [orientation]: offset || "0px", ...(position === "fixed" && { width: "100%", boxSizing: "border-box", diff --git a/src/components/progress-tracker/progress-tracker.spec.tsx b/src/components/progress-tracker/progress-tracker.spec.tsx index ae344cde9b..cc3f26015e 100644 --- a/src/components/progress-tracker/progress-tracker.spec.tsx +++ b/src/components/progress-tracker/progress-tracker.spec.tsx @@ -246,7 +246,7 @@ describe("ProgressTracker", () => { }); }); - describe("get a correct background of inner and outter bar color, when progress is 100 or the error occurs", () => { + describe("get a correct background of inner and outer bar color, when progress is 100 or the error occurs", () => { it("applies correct background color if progress is 100", () => { wrapper = mount(); assertStyleMatch( diff --git a/src/components/text-editor/text-editor.spec.tsx b/src/components/text-editor/text-editor.spec.tsx index e5f7bc09e1..1806c70877 100644 --- a/src/components/text-editor/text-editor.spec.tsx +++ b/src/components/text-editor/text-editor.spec.tsx @@ -1188,7 +1188,7 @@ describe("TextEditor", () => { ); }); - it("applies error styling when hasError is true and focusRedesignOptOut and isForcused are also true", () => { + it("applies error styling when hasError is true and focusRedesignOptOut and isFocused are also true", () => { const focusRedesignWrapper = mount( diff --git a/src/components/tooltip/tooltip.stories.mdx b/src/components/tooltip/tooltip.stories.mdx index d7c0375075..b3c7e8d183 100644 --- a/src/components/tooltip/tooltip.stories.mdx +++ b/src/components/tooltip/tooltip.stories.mdx @@ -78,7 +78,7 @@ Tooltip "bottom" intitially, then flip to "right" when there is not enough room when there is no space to render to the right anymore. - + ### Large tooltip diff --git a/src/components/tooltip/tooltip.stories.tsx b/src/components/tooltip/tooltip.stories.tsx index a223094c01..74921f3319 100644 --- a/src/components/tooltip/tooltip.stories.tsx +++ b/src/components/tooltip/tooltip.stories.tsx @@ -73,7 +73,7 @@ export const Positioning = () => { ); }; -export const FlipBehviourOverrides = () => { +export const FlipBehaviourOverrides = () => { const Component = forwardRef( ({ children }: ButtonProps, ref) => (