diff --git a/CHANGELOG.md b/CHANGELOG.md index c58ccaffd2..3cd4554ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [123.7.0](https://github.com/Sage/carbon/compare/v123.6.0...v123.7.0) (2023-11-08) + + +### Features + +* **select:** add selectionConfirmed property to event emitted on change ([748a17b](https://github.com/Sage/carbon/commit/748a17b8185a2d9a2bd58843efbd4a10739c2e8d)), closes [#6330](https://github.com/Sage/carbon/issues/6330) + + +### Bug Fixes + +* **filterable-select, multi-select:** handle when filter text has no match and enter pressed ([84ebef7](https://github.com/Sage/carbon/commit/84ebef7df4573d3205db9ebd424d88c2a26f4c3e)) +* **option:** ensure fill prop is not passed to underlying DOM element ([2a8cfc1](https://github.com/Sage/carbon/commit/2a8cfc11bae33c52496a65741ad02a339989beff)) + +## [123.6.0](https://github.com/Sage/carbon/compare/v123.5.1...v123.6.0) (2023-11-08) + + +### Features + +* **toast:** add alignY prop to allow vertical alignment to be configurable ([64203e1](https://github.com/Sage/carbon/commit/64203e1a618294c9c8c71964963377ae2891e134)), closes [#6301](https://github.com/Sage/carbon/issues/6301) + +### [123.5.1](https://github.com/Sage/carbon/compare/v123.5.0...v123.5.1) (2023-11-08) + + +### Bug Fixes + +* **multi-action-button, split-button:** fix aria role ([c16a1e4](https://github.com/Sage/carbon/commit/c16a1e4357518a122681675875999d61408a60b8)), closes [#6383](https://github.com/Sage/carbon/issues/6383) + +## [123.5.0](https://github.com/Sage/carbon/compare/v123.4.4...v123.5.0) (2023-11-07) + + +### Features + +* **icon:** 4 icons added to the icon font ([33f979a](https://github.com/Sage/carbon/commit/33f979ad1690fba0229a73e5999744168814378b)) + ### [123.4.4](https://github.com/Sage/carbon/compare/v123.4.3...v123.4.4) (2023-11-06) diff --git a/cypress/components/global-header/global-header.cy.tsx b/cypress/components/global-header/global-header.cy.tsx deleted file mode 100644 index 7f9ee0a3a0..0000000000 --- a/cypress/components/global-header/global-header.cy.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable jest/valid-expect, jest/valid-expect-in-promise */ -import React from "react"; -import GlobalHeader from "../../../src/components/global-header"; -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"; -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"); - - navigationBar().invoke("css", "zIndex").as("navigationBarZIndex"); - const globalIndex = parseInt(cy.get("@globalHeaderZIndex").toString()); - const NavIndex = parseInt(cy.get("@navigationBarZIndex").toString()); - - expect(globalIndex > NavIndex); - }); - - it("should check when logo prop is passed, the height of the logo element never exceeds the maximum height of the component", () => { - const logoHeight = 41; - const expectedHeight = 40; - - const logo = ( - - ); - CypressMountWithProviders(Example); - - globalHeaderLogo().should("have.css", "height", `${expectedHeight}px`); - }); - - describe("Accessibility tests for Global-Header component", () => { - it("should pass accessibility tests for Global-Header FullMenuExample", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Global-Header when logo prop is passed", () => { - const logoHeight = 41; - - const logo = ( - - ); - CypressMountWithProviders( - Example - ); - - cy.checkAccessibility(); - }); - }); -}); 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 0ec9430e69..e4da62af0c 100644 --- a/cypress/components/multi-action-button/multi-action-button.cy.tsx +++ b/cypress/components/multi-action-button/multi-action-button.cy.tsx @@ -720,6 +720,15 @@ context("Tests for MultiActionButton component", () => { cy.checkAccessibility(); }); + // TODO: test passes even when it shouldn't, see FE-6267 + it("should pass accessibility tests for MultiActionButton when open", () => { + CypressMountWithProviders(); + + multiActionButton().eq(0).trigger("keydown", keyCode("Enter")); + + cy.checkAccessibility(); + }); + it("should pass accessibility tests for MultiActionButton disabled prop", () => { CypressMountWithProviders(); diff --git a/cypress/components/select/filterable-select/filterable-select.cy.tsx b/cypress/components/select/filterable-select/filterable-select.cy.tsx index 3b0c855ee7..77901b2ac4 100644 --- a/cypress/components/select/filterable-select/filterable-select.cy.tsx +++ b/cypress/components/select/filterable-select/filterable-select.cy.tsx @@ -1018,6 +1018,7 @@ context("Tests for FilterableSelect component", () => { selectOption(positionOfElement(position)).click(); cy.get("@onChange").should("have.been.calledWith", { target: { value: option }, + selectionConfirmed: true, }); }); @@ -1147,6 +1148,93 @@ context("Tests for FilterableSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + dropdownButton().click(); + selectListText("Five").click(); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + dropdownButton().click(); + selectListText("Seven").click(); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-7"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option after filtering", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + commonDataElementInputPreview().click().type("th"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + }); + }); + + it("should not throw when filter text does not match option text", () => { + CypressMountWithProviders( + + ); + + commonDataElementInputPreview().type("abc"); + selectInput().realPress("Enter"); + getDataElementByValue("input").should("have.attr", "value", ""); + }); + describe("Accessibility tests for FilterableSelect component", () => { it("should pass accessibilty tests for FilterableSelect", () => { CypressMountWithProviders(); diff --git a/cypress/components/select/multi-select/multi-select.cy.tsx b/cypress/components/select/multi-select/multi-select.cy.tsx index f489129f92..149152d81c 100644 --- a/cypress/components/select/multi-select/multi-select.cy.tsx +++ b/cypress/components/select/multi-select/multi-select.cy.tsx @@ -915,6 +915,7 @@ context("Tests for MultiSelect component", () => { selectOption(positionOfElement(position)).click(); cy.get("@onChange").should("have.been.calledWith", { target: { value: option }, + selectionConfirmed: true, }); }); @@ -1099,6 +1100,141 @@ context("Tests for MultiSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + selectListText("Five").click(); + selectListText("Seven").click(); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when the selected options are removed via Backspace key", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 4); + + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 2); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 1); + selectInput().realPress("Backspace"); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 0); + }); + + it("is set on the event when the selected options are removed via clicking close icon of Pills", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + selectListText("Five").click(); + selectListText("Seven").click(); + + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 3); + pillCloseIcon().eq(2).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 2); + pillCloseIcon().eq(1).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 1); + pillCloseIcon().eq(0).click(); + cy.get('[data-element="confirmed-selections"]') + .children() + .should("have.length", 0); + }); + }); + + it("should not add an empty Pill when filter text does not match option text", () => { + CypressMountWithProviders(); + + multiSelectPill().should("not.exist"); + commonDataElementInputPreview().type("abc"); + selectInput().realPress("Enter"); + multiSelectPill().should("not.exist"); + }); + describe("Accessibility tests for MultiSelect component", () => { it("should pass accessibilty tests for MultiSelect", () => { CypressMountWithProviders(); diff --git a/cypress/components/select/simple-select/simple-select.cy.tsx b/cypress/components/select/simple-select/simple-select.cy.tsx index 7a96cd582f..2222149372 100644 --- a/cypress/components/select/simple-select/simple-select.cy.tsx +++ b/cypress/components/select/simple-select/simple-select.cy.tsx @@ -1007,6 +1007,80 @@ context("Tests for SimpleSelect component", () => { }); }); + describe("selection confirmed", () => { + it("is set on the event when options are clicked", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectListText("One").click(); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + dropdownButton().click(); + selectListText("Five").click(); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + dropdownButton().click(); + selectListText("Seven").click(); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowDown key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-1"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-3"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-3"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowDown"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-6"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option using ArrowUp key to navigate", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-9"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-7"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-7"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-5"]').should("exist"); + selectInput().realPress("ArrowUp"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-5"]').should("not.exist"); + cy.get('[data-element="confirmed-selection-4"]').should("exist"); + }); + + it("is set on the event when Enter key is pressed on an option after using alpha key", () => { + CypressMountWithProviders(); + + dropdownButton().click(); + selectInput().type("t", { force: true }); + cy.get('[data-element="confirmed-selection-2"]').should("not.exist"); + selectInput().realPress("Enter"); + cy.get('[data-element="confirmed-selection-2"]').should("exist"); + }); + }); + describe("Accessibility tests for SimpleSelect component", () => { it("should pass accessibility tests for SimpleSelect", () => { CypressMountWithProviders(); diff --git a/cypress/components/split-button/split-button.cy.tsx b/cypress/components/split-button/split-button.cy.tsx index cccd269bb0..f285530a86 100644 --- a/cypress/components/split-button/split-button.cy.tsx +++ b/cypress/components/split-button/split-button.cy.tsx @@ -754,6 +754,15 @@ context("Tests for SplitButton component", () => { cy.checkAccessibility(); }); + // TODO: test passes even when it shouldn't, see FE-6267 + it("should pass accessibility tests for SplitButton Default story when open", () => { + CypressMountWithProviders(); + + splitToggleButton().eq(0).trigger("keydown", keyCode("downarrow")); + + cy.checkAccessibility(); + }); + it("should pass accessibility tests for SplitButton Default story when the additional buttons are opened", () => { CypressMountWithProviders(); diff --git a/cypress/components/textbox/textbox.cy.tsx b/cypress/components/textbox/textbox.cy.tsx deleted file mode 100644 index 61e98c0f72..0000000000 --- a/cypress/components/textbox/textbox.cy.tsx +++ /dev/null @@ -1,984 +0,0 @@ -/* eslint-disable no-unused-expressions, jest/valid-expect */ -import React from "react"; -import { TextboxProps } from "components/textbox"; -import * as stories from "../../../src/components/textbox/textbox-test.stories"; -import * as defaultStories from "../../../src/components/textbox/textbox.stories"; -import Box from "../../../src/components/box"; -import CypressMountWithProviders from "../../support/component-helper/cypress-mount"; -import { - verifyRequiredAsteriskForLabel, - checkGoldenOutline, - assertCssValueIsApproximately, -} from "../../support/component-helper/common-steps"; - -import { - getDataElementByValue, - tooltipPreview, - characterCount, - body, - getComponent, - cyRoot, - fieldHelpPreview, - getElement, -} from "../../locators"; -import { - CHARACTERS, - SIZE, - VALIDATION, -} from "../../support/component-helper/constants"; - -import { - textbox, - textboxInput, - textboxPrefix, - visuallyHiddenCharacterCount, - visuallyHiddenHint, -} from "../../locators/textbox"; - -import { keyCode } from "../../support/helper"; -import Button from "../../../src/components/button"; -import { ICON } from "../../locators/locators"; - -const testData = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS]; -const keysToTrigger = ["Enter", "Space"] as const; - -const verifyOptional = (element: Cypress.Chainable>) => - element.then(($els) => { - // get Window reference from element - const win = $els[0].ownerDocument.defaultView; - // use getComputedStyle to read the pseudo selector - const after = win?.getComputedStyle($els[0], "after"); - // read the value of the `content` CSS property - const contentValue = after?.getPropertyValue("content"); - // the returned value will have double quotes around it, but this is correct - expect(contentValue).to.eq('"(optional)"'); - }); - -context("Tests for Textbox component", () => { - describe("check props for Textbox component", () => { - it.each([ - [SIZE.SMALL, "32px", "--sizing400"], - [SIZE.MEDIUM, "40px", "--sizing500"], - [SIZE.LARGE, "48px", "--sizing600"], - ] as [TextboxProps["size"], string, string][])( - "should use %s as size and render Textbox with %s as height", - (size, height, token) => { - CypressMountWithProviders(); - - textbox().should("have.css", "min-height", height); - textbox() - .getDesignTokensByCssProperty("min-height") - .should(($el) => { - expect($el[0]).to.equal(token); - }); - } - ); - - it.each([ - ["background", "--colorsUtilityYang100"], - ["border", "--colorsUtilityMajor300"], - ])("should check %s token for Textbox", (cssProp, token) => { - CypressMountWithProviders(); - - textbox() - .getDesignTokensByCssProperty(cssProp) - .should(($el) => { - expect($el[0]).to.equal(token); - }); - }); - - it("should render Textbox with isOptional prop", () => { - CypressMountWithProviders(); - - verifyOptional(getDataElementByValue("label").parent()); - }); - - it("should render Textbox with data-component prop", () => { - CypressMountWithProviders( - - ); - - getComponent(CHARACTERS.STANDARD).should("be.visible"); - }); - - it("should render Textbox with data-element prop", () => { - CypressMountWithProviders( - - ); - cyRoot() - .children() - .children() - .should("have.attr", "data-element", CHARACTERS.STANDARD); - }); - - it("should render Textbox with data-role prop", () => { - CypressMountWithProviders( - - ); - - cyRoot() - .children() - .children() - .should("have.attr", "data-role", CHARACTERS.STANDARD); - }); - - it("should render Textbox with id prop", () => { - CypressMountWithProviders( - - ); - - textboxInput().should("have.attr", "id", CHARACTERS.STANDARD); - }); - - it.each(testData)( - "should render Textbox with label prop set to %s", - (label) => { - CypressMountWithProviders(); - - getDataElementByValue("label").should("have.text", label); - } - ); - - it("should render Textbox with labelInline prop", () => { - CypressMountWithProviders(); - - getDataElementByValue("label") - .parent() - .should("have.css", "justify-content", "flex-end"); - }); - - it.each([ - ["left", "start"], - ["right", "end"], - ] as [TextboxProps["labelAlign"], string][])( - "should render Textbox with labelAlign prop set to %s", - (labelAlign, cssValue) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .parent() - .should(($element) => - expect($element).to.have.css("justify-content", `flex-${cssValue}`) - ); - } - ); - - it.each(testData)( - "should render Textbox with labelHelp prop", - (labelHelp) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("question").trigger("mouseover"); - tooltipPreview().should("have.text", labelHelp); - } - ); - - it.each([ - [1, "8px"], - [2, "16px"], - ] as [TextboxProps["labelSpacing"], string][])( - "should render Textbox with labelSpacing prop set to %s", - (spacing, padding) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .parent() - .should("have.css", "padding-right", padding); - } - ); - - it.each([ - [10, 90, 135, 1229], - [30, 70, 409, 956], - [80, 20, 1092, 273], - ] as [TextboxProps["labelWidth"], TextboxProps["inputWidth"], number, number][])( - "should use %s as labelWidth, %s as inputWidth and render it with correct label and input width ratios", - (label, input, labelRatio, inputRatio) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .parent() - .then(($el) => { - assertCssValueIsApproximately($el, "width", labelRatio); - }); - - getDataElementByValue("input") - .parent() - .then(($el) => { - assertCssValueIsApproximately($el, "width", inputRatio); - }); - } - ); - - it.each([ - ["foo", "exist"], - ["", "not.exist"], - ])( - "input hint should be conditionally rendered", - (inputHint, renderStatus) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("input-hint").should(renderStatus); - } - ); - - it.each([ - [5, "exist"], - [null, "not.exist"], - ] as [TextboxProps["characterLimit"], string][])( - "character counter should be conditionally rendered", - (characterLimit, renderStatus) => { - CypressMountWithProviders( - - ); - - characterCount().should(renderStatus); - } - ); - - it.each([ - [5, "exist"], - [null, "not.exist"], - ] as [TextboxProps["characterLimit"], string][])( - "visually hidden character count should be conditionally rendered", - (characterLimit, renderStatus) => { - CypressMountWithProviders( - - ); - - visuallyHiddenCharacterCount().should(renderStatus); - } - ); - - it.each([ - [5, "exist"], - [null, "not.exist"], - ] as [TextboxProps["characterLimit"], string][])( - "visually hidden hint should be conditionally rendered", - (characterLimit, renderStatus) => { - CypressMountWithProviders( - - ); - - visuallyHiddenHint().should(renderStatus); - } - ); - - it.each(["10%", "30%", "50%", "80%", "100%"] as TextboxProps["maxWidth"][])( - "should check maxWidth as %s for TextBox component", - (maxWidth) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("input") - .parent() - .parent() - .should("have.css", "max-width", maxWidth); - } - ); - - it("when maxWidth has no value it should render as 100%", () => { - CypressMountWithProviders(); - - getDataElementByValue("input") - .parent() - .parent() - .should("have.css", "max-width", "100%"); - }); - - it("should render Textbox with required prop", () => { - CypressMountWithProviders(); - - verifyRequiredAsteriskForLabel(); - }); - - it.each([ - [11, 11, "rgba(0, 0, 0, 0.55)"], - [11, 10, "rgb(203, 55, 74)"], - ])( - "should input %s characters and warn if over character limit of %s in Textbox", - (charactersUsed, limit, color) => { - const inputValue = "12345678901"; - const underCharacters = - limit - charactersUsed === 1 ? "character" : "characters"; - const overCharacters = - charactersUsed - limit === 1 ? "character" : "characters"; - - CypressMountWithProviders( - - ); - - textboxInput() - .type(inputValue) - .then(() => { - characterCount() - .should( - "have.text", - `${ - charactersUsed - limit - ? `${charactersUsed - limit} ${overCharacters} too many` - : `${charactersUsed - limit} ${underCharacters} left` - }` - ) - .and("have.css", "color", color); - - visuallyHiddenCharacterCount().should( - "have.text", - `${ - charactersUsed - limit - ? `${charactersUsed - limit} ${overCharacters} too many` - : `${charactersUsed - limit} ${underCharacters} left` - }` - ); - }); - } - ); - - it("should render Textbox with name prop", () => { - CypressMountWithProviders( - - ); - - textboxInput().should("have.attr", "name", CHARACTERS.STANDARD); - }); - - it("should render Textbox with disabled prop", () => { - CypressMountWithProviders(); - - textboxInput().should("be.disabled").and("have.attr", "disabled"); - }); - - it("should render Textbox icon with disabled style", () => { - CypressMountWithProviders( - - ); - - textbox() - .find(ICON) - .should("be.visible") - .and("have.css", "color", "rgba(0, 0, 0, 0.3)"); - }); - - it.each(testData)( - "should render Textbox with placeholder prop set to %s", - (placeholder) => { - CypressMountWithProviders( - - ); - - textboxInput().should("have.attr", "placeholder", placeholder); - } - ); - - it("should render Textbox with autoFocus prop and correct styling when focusRedesignOptOut is false", () => { - CypressMountWithProviders( - , - undefined, - undefined, - { - focusRedesignOptOut: false, - } - ); - - body().tab(); - textboxInput() - .should("be.focused") - .parent() - .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 render Textbox with autoFocus prop and correct styling when focusRedesignOptOut is true", () => { - CypressMountWithProviders( - , - undefined, - undefined, - { - focusRedesignOptOut: true, - } - ); - - body().tab(); - textboxInput() - .should("be.focused") - .then(($el) => { - checkGoldenOutline($el.parent()); - }); - }); - - it("should render Textbox with readOnly prop", () => { - CypressMountWithProviders(); - - textboxInput().and("have.attr", "readOnly"); - }); - - it("should render Textbox icon with readOnly style", () => { - CypressMountWithProviders( - - ); - - textbox() - .find(ICON) - .should("be.visible") - .and("have.css", "color", "rgba(0, 0, 0, 0.3)"); - }); - - it.each(["error", "warning", "info"])( - "should verify Textbox is displayed with %s validation icon on input", - (type) => { - CypressMountWithProviders( - - ); - - textbox().find(ICON).should("have.attr", "data-element", type); - } - ); - - it.each(["error", "warning", "info"])( - "should verify Textbox is displayed with %s validation icon on label", - (type) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .parent() - .find(ICON) - .should("have.attr", "data-element", type); - } - ); - - it.each([ - [VALIDATION.ERROR, "error", true], - [VALIDATION.WARNING, "warning", true], - [VALIDATION.INFO, "info", true], - ["rgb(102, 132, 148)", "error", false], - ["rgb(102, 132, 148)", "warning", false], - ["rgb(102, 132, 148)", "info", false], - ])( - "should verify Textbox input border colour is %s when validation is %s and boolean prop is %s", - (borderColor, type, bool) => { - CypressMountWithProviders( - - ); - - textbox().should("have.css", "border-bottom-color", borderColor); - } - ); - - it("should render Textbox with leftChildren prop", () => { - CypressMountWithProviders( - Test} /> - ); - - textbox() - .children() - .should("have.attr", "data-component", "button") - .and("be.visible"); - }); - - it.each(testData)( - "should render Textbox with prefix prop set to %s", - (prefix) => { - CypressMountWithProviders(); - - textboxPrefix() - .should("have.text", prefix) - .and("have.css", "font-size", "14px") - .and("have.css", "font-weight", "900") - .and("have.css", "margin-left", "12px"); - } - ); - - it("when prefix prop is set, and align is set to 'right', 'flex-direction' should be 'row'", () => { - CypressMountWithProviders( - - ); - - getDataElementByValue("input") - .parent() - .should("have.css", "flex-direction", "row"); - }); - - it.each([ - [true, "input", "label"], - [false, "label", "input"], - ])( - "should render Textbox with reverse prop set to %s", - (boolean, firstElement, secondElement) => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label").parent().parent().as("startPoint"); - - cy.get("@startPoint") - .children() - .eq(0) - .find(firstElement) - .should("be.visible"); - cy.get("@startPoint") - .children() - .eq(1) - .find(secondElement) - .should("be.visible"); - } - ); - - it("should render Textbox with positionedChildren prop", () => { - CypressMountWithProviders( - Test} /> - ); - - textbox() - .parent() - .children() - .should("have.attr", "data-component", "button") - .and("be.visible"); - }); - - it.each([ - ["flex", 399], - ["flex", 400], - ["block", 401], - ] as [string, TextboxProps["adaptiveLabelBreakpoint"]][])( - "should check Textbox label alignment is %s with adaptiveLabelBreakpoint %s and viewport 400", - (displayValue, breakpoint) => { - cy.viewport(400, 300); - - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .parent() - .parent() - .should("have.css", "display", displayValue); - } - ); - - it("should render Textbox with labelId prop", () => { - CypressMountWithProviders( - - ); - - getDataElementByValue("label") - .should("have.attr", "id", `${CHARACTERS.STANDARD}_cy-label`) - .and("have.attr", "for", `${CHARACTERS.STANDARD}_cy`); - }); - - it.each(testData)( - "should render Textbox with fieldHelp prop set to %s", - (fieldHelp) => { - CypressMountWithProviders( - - ); - - fieldHelpPreview().should("have.text", fieldHelp); - } - ); - - it("should render Textbox with formattedValue prop", () => { - CypressMountWithProviders( - - ); - - textboxInput().should("have.attr", "value", CHARACTERS.STANDARD); - }); - - it.each(["add", "filter", "play"] as TextboxProps["inputIcon"][])( - "should render Textbox with inputIcon prop set to %s", - (icon) => { - CypressMountWithProviders( - - ); - - getElement(icon).and("be.visible"); - } - ); - - it("should render Textbox with iconTabIndex prop", () => { - CypressMountWithProviders( - - ); - - getDataElementByValue("add").parent().should("have.attr", "tabindex", 25); - }); - - it.each([ - "top", - "bottom", - "left", - "right", - ] as TextboxProps["tooltipPosition"][])( - "should render Textbox component with tooltip positioned to the %s", - (position) => { - CypressMountWithProviders( - - - - ); - getDataElementByValue("error").trigger("mouseover"); - tooltipPreview() - .should("have.text", CHARACTERS.STANDARD) - .should("have.attr", "data-placement", position); - } - ); - - it("should render Textbox with helpAriaLabel prop", () => { - CypressMountWithProviders( - - ); - - getComponent("help").should( - "have.attr", - "aria-label", - CHARACTERS.STANDARD - ); - }); - - it.each(["left", "right"] as TextboxProps["align"][])( - "should render Textbox with align prop set to %s", - (align) => { - CypressMountWithProviders(); - - textboxInput().should("have.css", "text-align", align); - } - ); - - it("should render Textbox with inputRef prop and focus the input via click on ref component", () => { - CypressMountWithProviders(); - - getComponent("button").click(); - textboxInput().should("be.focused"); - }); - - it.each(testData)( - "should input into Textbox and verify the value", - (input) => { - CypressMountWithProviders(); - - textboxInput().type(input).should("have.attr", "value", input); - } - ); - }); - - describe("check events for Textbox component", () => { - const inputValue = "1"; - - it("should call onChange callback when a type event is triggered", () => { - const callback: TextboxProps["onChange"] = cy.stub(); - - CypressMountWithProviders( - - ); - - textboxInput() - .type(inputValue) - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call onBlur callback when a blur event is triggered", () => { - const callback: TextboxProps["onBlur"] = cy.stub(); - - CypressMountWithProviders(); - - textboxInput() - .click() - .blur() - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call onClick callback when a click event is triggered", () => { - const callback: TextboxProps["onClick"] = cy.stub(); - - CypressMountWithProviders( - - ); - - textboxInput() - .click() - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call onFocus callback when a focus event is triggered", () => { - const callback: TextboxProps["onFocus"] = cy.stub(); - - CypressMountWithProviders( - - ); - - textboxInput() - .focus() - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call onMouseDown callback when a mousedown event is triggered", () => { - const callback: TextboxProps["onMouseDown"] = cy.stub(); - - CypressMountWithProviders( - - ); - - textboxInput() - .trigger("mousedown") - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call iconOnMouseDown callback when a click event is triggered", () => { - const callback: TextboxProps["iconOnMouseDown"] = cy.stub(); - - CypressMountWithProviders( - - ); - - getComponent("icon") - .click() - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it("should call iconOnClick callback when a click event is triggered", () => { - const callback: TextboxProps["iconOnClick"] = cy.stub(); - - CypressMountWithProviders( - - ); - - getComponent("icon") - .click() - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - }); - - it.each([keysToTrigger[0], keysToTrigger[1]])( - "should call iconOnClick callback when %s key is triggered", - (key) => { - const callback: TextboxProps["iconOnClick"] = cy.stub(); - - CypressMountWithProviders( - - ); - - getComponent("icon") - .trigger("keydown", { ...keyCode(key), force: true }) - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - } - ); - - it.each([keysToTrigger[0], keysToTrigger[1]])( - "should call onKeyDown callback when %s key is triggered", - (key) => { - const callback: TextboxProps["onKeyDown"] = cy.stub(); - - CypressMountWithProviders( - - ); - - textboxInput() - .focus() - .trigger("keydown", { ...keyCode(key), force: true }) - .then(() => { - expect(callback).to.have.been.calledOnce; - }); - } - ); - }); - - describe("Accessibility tests for Textbox component", () => { - it("should pass accessibility tests for Textbox default story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox autoFocus story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox characterCounter story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox disabled story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox LabelAlign story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox Margins story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox NewDesignsValidation story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox Prefix story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ReadOnly story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox Required story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox Sizes story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ValidationsAsABoolean story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ValidationsAsAString story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ValidationsAsAStringDisplayedOnLabel story", () => { - CypressMountWithProviders( - - ); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ValidationsAsAStringWithTooltipCustom story", () => { - CypressMountWithProviders( - - ); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox ValidationsAsAStringWithTooltipDefault story", () => { - CypressMountWithProviders( - - ); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox WithCustomLabelWidthAndInputWidth story", () => { - CypressMountWithProviders( - - ); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox WithCustomMaxWidth story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox WithFieldHelp story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox WithLabelHelp story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - - it("should pass accessibility tests for Textbox WithLabelInline story", () => { - CypressMountWithProviders(); - - cy.checkAccessibility(); - }); - }); - - it("should have the expected border radius styling", () => { - CypressMountWithProviders(); - getElement("input").should("have.css", "border-radius", "4px"); - }); -}); diff --git a/cypress/components/toast/toast.cy.tsx b/cypress/components/toast/toast.cy.tsx index b3e9f053da..328da3e318 100644 --- a/cypress/components/toast/toast.cy.tsx +++ b/cypress/components/toast/toast.cy.tsx @@ -1,6 +1,9 @@ import React from "react"; import { ToastProps } from "components/toast"; -import { ToastComponent } from "../../../src/components/toast/toast-test.stories"; +import { + ToastComponent, + AllAlign, +} from "../../../src/components/toast/toast-test.stories"; import { TOAST_COLORS } from "../../../src/components/toast/toast.config"; import CypressMountWithProviders from "../../support/component-helper/cypress-mount"; import toastComponent from "../../locators/toast"; @@ -204,6 +207,19 @@ context("Testing Toast component", () => { .should("have.css", "justify-content", align); } ); + it.each(["top", "bottom"] as ToastProps["alignY"][])( + "should render Toast component alignY prop set to %s", + (alignY) => { + CypressMountWithProviders(); + + toastComponent() + .parent() + .parent() + .parent() + .parent() + .should("have.css", alignY, "0px"); + } + ); }); describe("check events for Toast component", () => { @@ -248,6 +264,11 @@ context("Testing Toast component", () => { cy.checkAccessibility(); }); + + it("should render Toast components with all align combinations and check accessibility", () => { + CypressMountWithProviders(); + cy.checkAccessibility(); + }); }); it("render with the expected border radius", () => { diff --git a/package-lock.json b/package-lock.json index 4e5e450dde..bc48fe16fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "carbon-react", - "version": "123.4.4", + "version": "123.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "carbon-react", - "version": "123.4.4", + "version": "123.7.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 77362c5033..8b4ebf9e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carbon-react", - "version": "123.4.4", + "version": "123.7.0", "description": "A library of reusable React components for easily building user interfaces.", "files": [ "lib", diff --git a/playwright/components/global-header/index.ts b/playwright/components/global-header/index.ts new file mode 100644 index 0000000000..03d3d90a4d --- /dev/null +++ b/playwright/components/global-header/index.ts @@ -0,0 +1,6 @@ +import { Page } from "playwright-core"; +import { GLOBAL_HEADER, GLOBAL_HEADER_LOGO_WRAPPER } from "./locators"; + +export const globalHeader = (page: Page) => page.locator(GLOBAL_HEADER); +export const globalHeaderLogo = (page: Page) => + page.locator(GLOBAL_HEADER_LOGO_WRAPPER).nth(0).first(); diff --git a/playwright/components/global-header/locators.ts b/playwright/components/global-header/locators.ts new file mode 100644 index 0000000000..82090117b6 --- /dev/null +++ b/playwright/components/global-header/locators.ts @@ -0,0 +1,4 @@ +// component preview locators +export const GLOBAL_HEADER = "[data-component='global-header']"; +export const GLOBAL_HEADER_LOGO_WRAPPER = + "[data-element='global-header-logo-wrapper']"; diff --git a/playwright/components/index.ts b/playwright/components/index.ts index 2949a58c58..82fb9eb01b 100644 --- a/playwright/components/index.ts +++ b/playwright/components/index.ts @@ -8,6 +8,7 @@ import { DLS_ROOT, FIELD_HELP_PREVIEW, LABEL, + COMMON_INPUT_CHARACTER_LIMIT, STICKY_FOOTER, COMMMON_DATA_ELEMENT_INPUT, PORTAL, @@ -30,6 +31,14 @@ export const button = (page: Page) => { return page.locator(BUTTON); }; +export const getDataComponentByValue = (page: Page, element: string) => { + return page.locator(`[data-component="${element}"]`); +}; + +export const getDataRoleByValue = (page: Page, element: string) => { + return page.locator(`[data-role="${element}"]`); +}; + export const closeIconButton = (page: Page) => { return page.locator(CLOSE_ICON_BUTTON); }; @@ -70,6 +79,10 @@ export const label = (page: Page) => { return page.locator(LABEL); }; +export const characterLimit = (page: Page) => { + return page.locator(COMMON_INPUT_CHARACTER_LIMIT); +}; + export const legendSpan = (page: Page) => { return page.locator("legend > span"); }; diff --git a/playwright/components/locators.ts b/playwright/components/locators.ts index 40d67c7e82..8c0cf4c454 100644 --- a/playwright/components/locators.ts +++ b/playwright/components/locators.ts @@ -18,7 +18,7 @@ export const COMMON_INPUT = ".common-input__"; export const BUTTON = 'button[type="button"]'; export const COMMON_INPUT_PREFIX = '[data-element="textbox-prefix"]'; export const COMMON_INPUT_CHARACTER_LIMIT = - 'div[data-element="character-limit"]'; + 'div[data-element="character-count"]'; export const PORTAL = ".carbon-portal"; export const LEGEND = "legend"; export const STICKY_FOOTER = '[data-component="sticky-footer"]'; diff --git a/playwright/components/textbox/index.ts b/playwright/components/textbox/index.ts index 1e22797c51..822515f65c 100644 --- a/playwright/components/textbox/index.ts +++ b/playwright/components/textbox/index.ts @@ -16,5 +16,5 @@ export const textboxPrefix = (page: Page) => { }; export const textboxInput = (page: Page) => { - return page.locator(TEXTBOX).locator("div").first(); + return page.locator(TEXTBOX).locator("input"); }; diff --git a/src/components/button/button.component.tsx b/src/components/button/button.component.tsx index 7572ee4907..e82fbf9d06 100644 --- a/src/components/button/button.component.tsx +++ b/src/components/button/button.component.tsx @@ -293,7 +293,7 @@ const Button = React.forwardRef( buttonType={buttonType} disabled={disabled} destructive={destructive} - role={inSplitButton ? "menu-item" : "button"} + role={inSplitButton ? "menuitem" : "button"} type={href ? undefined : "button"} iconType={iconType} size={size} diff --git a/src/components/global-header/component.test-pw.tsx b/src/components/global-header/component.test-pw.tsx new file mode 100644 index 0000000000..c4e9f3b9b4 --- /dev/null +++ b/src/components/global-header/component.test-pw.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from "react"; +import carbonlogo from "../../../logo/carbon-logo.png"; +import GlobalHeader, { GlobalHeaderProps } from "./global-header.component"; +import { Menu, MenuItem, MenuDivider } from "../menu"; +import NavigationBar from "../navigation-bar"; + +export const FullMenuExample = () => ( + <> + + + + Product A + + + Child Item 1 + Child Item 2 + Child Item 3 + + + Child Item + + + + + + Menu Item One + + Menu Item Two + + + Item Submenu One + Item Submenu Two + + + Item Submenu Three + + Item Submenu Four + + + Item Submenu One + Item Submenu Two + + + + > +); + +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} + > + ); +}; + +export const GlobalHeaderWithLogo = () => { + const logoHeight = 41; + const logo = ( + + ); + return ( + <> + Example + > + ); +}; diff --git a/src/components/global-header/global-header-test.stories.tsx b/src/components/global-header/global-header-test.stories.tsx index bcff7fd3a5..1d0cab8c68 100644 --- a/src/components/global-header/global-header-test.stories.tsx +++ b/src/components/global-header/global-header-test.stories.tsx @@ -1,10 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { ComponentMeta, ComponentStory } from "@storybook/react"; -import GlobalHeader, { GlobalHeaderProps } from "./global-header.component"; -import { Menu, MenuItem, MenuDivider } from "../menu"; +import GlobalHeader from "./global-header.component"; +import { Menu, MenuItem } from "../menu"; import VerticalDivider from "../vertical-divider"; -import NavigationBar from "../navigation-bar"; import carbonLogo from "../../../logo/carbon-logo.png"; export default { @@ -55,64 +54,3 @@ MenuWithIconOnlyButtonsStory.parameters = { }, }, }; - -export const FullMenuExample = () => ( - <> - - - - Product A - - - Child Item 1 - Child Item 2 - Child Item 3 - - - Child Item - - - - - - Menu Item One - - Menu Item Two - - - Item Submenu One - Item Submenu Two - - - Item Submenu Three - - Item Submenu Four - - - Item Submenu One - Item Submenu Two - - - - > -); - -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.pw.tsx b/src/components/global-header/global-header.pw.tsx new file mode 100644 index 0000000000..0a727ee4d1 --- /dev/null +++ b/src/components/global-header/global-header.pw.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { test, expect } from "@playwright/experimental-ct-react17"; +import { + FullMenuExample, + GlobalHeaderWithErrorHandler, + GlobalHeaderWithLogo, +} from "./component.test-pw"; +import navigationBar from "../../../playwright/components/navigation-bar"; +import { + globalHeader, + globalHeaderLogo, +} from "../../../playwright/components/global-header"; +import { checkAccessibility } from "../../../playwright/support/helper"; + +test.describe("Global Header component", () => { + test("should render without causing a ResiveObserver related error", async ({ + mount, + page, + }) => { + await mount(); + + await page.waitForTimeout(500); + + const errorState = page.locator("#error-div"); + await expect(errorState).toHaveText(""); + }); + + test("should render with z-index of component greater than z-index of NavigationBar", async ({ + mount, + page, + }) => { + await mount(); + + const globalHeaderZIndex = await globalHeader(page).evaluate((element) => { + const style = getComputedStyle(element); + return style.zIndex; + }); + + const navigationBarZIndex = await navigationBar(page).evaluate( + (element) => { + const style = getComputedStyle(element); + return style.zIndex; + } + ); + + const globalIndex = parseInt(globalHeaderZIndex.toString()); + const NavIndex = parseInt(navigationBarZIndex.toString()); + + expect(globalIndex).toBeGreaterThan(NavIndex); + }); + + test("should render with height of logo element never exceeding the max height of the component when logo prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await expect(globalHeaderLogo(page)).toHaveCSS("height", `40px`); + }); + + test.describe("Accessibility tests", () => { + test("should pass tests for FullMenuExample", async ({ mount, page }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass tests for GlobalHeaderWithLogo example", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + }); +}); diff --git a/src/components/icon/__snapshots__/icon-unicodes.spec.js.snap b/src/components/icon/__snapshots__/icon-unicodes.spec.js.snap index f3b89dec12..83d5363489 100644 --- a/src/components/icon/__snapshots__/icon-unicodes.spec.js.snap +++ b/src/components/icon/__snapshots__/icon-unicodes.spec.js.snap @@ -85,12 +85,14 @@ Object { "circles_connection": "\\\\e979", "clock": "\\\\e919", "close": "\\\\e91e", + "cloud_co2": "\\\\f060", "coins": "\\\\e96a", "collaborate": "\\\\e946", "computer_clock": "\\\\e997", "connect": "\\\\e955", "connect_off": "\\\\f053", "construction": "\\\\f008", + "contact_card": "\\\\f059", "contacts": "\\\\e93b", "copy": "\\\\e91b", "create": "\\\\e940", @@ -166,12 +168,14 @@ Object { "job_seeked": "\\\\f037", "key": "\\\\e92b", "laptop": "\\\\f012", + "leaf": "\\\\f061", "ledger": "\\\\e973", "ledger_arrow_left": "\\\\e971", "ledger_arrow_right": "\\\\e972", "lightbulb_off": "\\\\e95c", "lightbulb_on": "\\\\e95d", "like": "\\\\f056", + "like_no": "\\\\f058", "link": "\\\\e92d", "link_cloud": "\\\\f036", "link_on": "\\\\f043", diff --git a/src/components/icon/fonts/carbon-icons-webfont.woff b/src/components/icon/fonts/carbon-icons-webfont.woff index 972be7184c..8d1d40d862 100644 Binary files a/src/components/icon/fonts/carbon-icons-webfont.woff and b/src/components/icon/fonts/carbon-icons-webfont.woff differ diff --git a/src/components/icon/icon-config.ts b/src/components/icon/icon-config.ts index c858802323..b109d1dd2b 100644 --- a/src/components/icon/icon-config.ts +++ b/src/components/icon/icon-config.ts @@ -131,6 +131,7 @@ export const ICONS: IconType[] = [ "circles_connection", "clock", "close", + "cloud_co2", "coins", "collaborate", "computer_clock", @@ -138,6 +139,7 @@ export const ICONS: IconType[] = [ "connect_off", "construction", "contacts", + "contact_card", "copy", "create", "credit_card", @@ -212,12 +214,14 @@ export const ICONS: IconType[] = [ "job_seeked", "key", "laptop", + "leaf", "ledger", "ledger_arrow_left", "ledger_arrow_right", "lightbulb_off", "lightbulb_on", "like", + "like_no", "link", "link_cloud", "link_on", diff --git a/src/components/icon/icon-type.ts b/src/components/icon/icon-type.ts index 390769abe4..1604d0badb 100644 --- a/src/components/icon/icon-type.ts +++ b/src/components/icon/icon-type.ts @@ -82,6 +82,7 @@ export type IconType = | "circles_connection" | "clock" | "close" + | "cloud_co2" | "coins" | "collaborate" | "computer_clock" @@ -89,6 +90,7 @@ export type IconType = | "connect_off" | "construction" | "contacts" + | "contact_card" | "copy" | "create" | "credit_card" @@ -163,10 +165,12 @@ export type IconType = | "job_seeked" | "key" | "laptop" + | "leaf" | "ledger" | "ledger_arrow_left" | "ledger_arrow_right" | "like" + | "like_no" | "link" | "lightbulb_off" | "lightbulb_on" diff --git a/src/components/icon/icon-unicodes.js b/src/components/icon/icon-unicodes.js index a5512c5137..b6394ecdb0 100644 --- a/src/components/icon/icon-unicodes.js +++ b/src/components/icon/icon-unicodes.js @@ -250,6 +250,10 @@ const misc = { volunteering: "\\f039", website: "\\f051", welfare: "\\f034", + contact_card: "\\f059", + cloud_co2: "\\f060", + leaf: "\\f061", + like_no: "\\f058", }; const legacyNames = { diff --git a/src/components/select/filterable-select/filterable-select-test.stories.tsx b/src/components/select/filterable-select/filterable-select-test.stories.tsx index 4953773c01..e5aace0979 100644 --- a/src/components/select/filterable-select/filterable-select-test.stories.tsx +++ b/src/components/select/filterable-select/filterable-select-test.stories.tsx @@ -1,6 +1,11 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import partialAction from "../../../../.storybook/utils/partial-action"; -import { FilterableSelect, Option, FilterableSelectProps } from ".."; +import { + FilterableSelect, + Option, + FilterableSelectProps, + CustomSelectChangeEvent, +} from ".."; import OptionRow from "../option-row/option-row.component"; import Dialog from "../../dialog"; import Button from "../../button"; @@ -8,6 +13,7 @@ import Button from "../../button"; export default { component: FilterableSelect, title: "Select/Filterable/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -49,7 +55,7 @@ DefaultStory.storyName = "default"; export const FilterableSelectComponent = ( props: Partial ) => { - const [value, setValue] = React.useState(""); + const [value, setValue] = useState(""); function onChangeHandler(event: React.ChangeEvent) { setValue(event.target.value); @@ -87,9 +93,9 @@ export const FilterableSelectComponent = ( export const FilterableSelectWithLazyLoadingComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); - const [value, setValue] = React.useState("black"); - const [isLoading, setIsLoading] = React.useState(true); + const preventLoading = useRef(false); + const [value, setValue] = useState("black"); + const [isLoading, setIsLoading] = useState(true); const asyncList = [ , , @@ -97,7 +103,7 @@ export const FilterableSelectWithLazyLoadingComponent = ( , , ]; - const [optionList, setOptionList] = React.useState([ + const [optionList, setOptionList] = useState([ , ]); @@ -135,7 +141,7 @@ export const FilterableSelectWithLazyLoadingComponent = ( export const FilterableSelectLazyLoadTwiceComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); + const preventLoading = useRef(false); const [value, setValue] = useState(""); const [isLoading, setIsLoading] = useState(true); const asyncList = [ @@ -186,9 +192,9 @@ export const FilterableSelectLazyLoadTwiceComponent = ( export const FilterableSelectWithInfiniteScrollComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); - const preventLazyLoading = React.useRef(false); - const lazyLoadingCounter = React.useRef(0); + const preventLoading = useRef(false); + const preventLazyLoading = useRef(false); + const lazyLoadingCounter = useRef(0); const [value, setValue] = useState(""); const [isLoading, setIsLoading] = useState(true); const asyncList = [ @@ -277,7 +283,7 @@ export const FilterableSelectObjectAsValueComponent = ( value: 5, text: "Green", }); - const optionList = React.useRef([ + const optionList = useRef([ { - const [value, setValue] = React.useState(""); - const [isOpen, setIsOpen] = React.useState(false); - const [optionList, setOptionList] = React.useState([ + const [value, setValue] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [optionList, setOptionList] = useState([ , , , @@ -554,7 +560,7 @@ export const FilterableSelectOnChangeEventComponent = ({ onChange, ...props }: Partial) => { - const [state, setState] = React.useState(""); + const [state, setState] = useState(""); const setValue = (event: React.ChangeEvent) => { setState(event.target.value); @@ -583,7 +589,7 @@ export const FilterableSelectOnChangeEventComponent = ({ export const FilterableSelectListActionEventComponent = ( props: Partial ) => { - const [value, setValue] = React.useState(""); + const [value, setValue] = useState(""); return ( { ); }; + +export const SelectionConfirmed = () => { + const [value, setValue] = useState(""); + const [confirmedSelection, setConfirmedSelection] = useState(""); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue(event.target.value); + if (event.selectionConfirmed) { + setConfirmedSelection(event.target.value); + } + }; + return ( + <> + + + + + + + + + + + + + {confirmedSelection ? ( + + {confirmedSelection} + + ) : null} + > + ); +}; diff --git a/src/components/select/filterable-select/filterable-select.component.tsx b/src/components/select/filterable-select/filterable-select.component.tsx index e6ea9592e0..9f86ec103b 100644 --- a/src/components/select/filterable-select/filterable-select.component.tsx +++ b/src/components/select/filterable-select/filterable-select.component.tsx @@ -17,6 +17,8 @@ import Logger from "../../../__internal__/utils/logger"; import useStableCallback from "../../../hooks/__internal__/useStableCallback"; import useFormSpacing from "../../../hooks/__internal__/useFormSpacing"; import useInputAccessibility from "../../../hooks/__internal__/useInputAccessibility/useInputAccessibility"; +import { CustomSelectChangeEvent } from "../simple-select"; +import { OptionData } from "../simple-select/simple-select.component"; let deprecateInputRefWarnTriggered = false; let deprecateUncontrolledWarnTriggered = false; @@ -127,7 +129,7 @@ export const FilterableSelect = React.forwardRef( }: FilterableSelectProps, ref ) => { - const [activeDescendantId, setActiveDescendantId] = useState(); + const [activeDescendantId, setActiveDescendantId] = useState(); const selectListId = useRef(guid()); const containerRef = useRef(null); const listboxRef = useRef(null); @@ -169,24 +171,25 @@ export const FilterableSelect = React.forwardRef( } const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); const triggerChange = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { if (onChange) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } }, [onChange, createCustomEvent] @@ -199,21 +202,19 @@ export const FilterableSelect = React.forwardRef( return (list as React.ReactElement[]).find((child) => { const { text } = child.props; - return ( - text && text.toLowerCase().indexOf(textToMatch.toLowerCase()) !== -1 - ); + return text?.toLowerCase().indexOf(textToMatch?.toLowerCase()) !== -1; }); } const updateValues = useCallback( - (newFilterText, isDeleteEvent) => { + (newFilterText: string, isDeleteEvent: boolean) => { setSelectedValue((previousValue) => { const match = findElementWithMatchingText(newFilterText, children); const isFilterCleared = isDeleteEvent && newFilterText === ""; if (!match || isFilterCleared) { setTextValue(newFilterText); - triggerChange(""); + triggerChange("", false); return ""; } @@ -224,11 +225,11 @@ export const FilterableSelect = React.forwardRef( return match.props.value; } - triggerChange(match.props.value); + triggerChange(match.props.value, false); if ( match.props.text - .toLowerCase() + ?.toLowerCase() .startsWith(newFilterText.toLowerCase()) ) { setTextValue(match.props.text); @@ -262,8 +263,8 @@ export const FilterableSelect = React.forwardRef( } else if ( isClosing || matchingOption.props.text - .toLowerCase() - .startsWith(filterText.toLowerCase()) + ?.toLowerCase() + .startsWith(filterText?.toLowerCase()) ) { setTextValue(matchingOption.props.text); } @@ -290,7 +291,7 @@ export const FilterableSelect = React.forwardRef( (key) => { setFilterText((previousFilterText) => { if ( - previousFilterText.length === textValue.length - 1 && + previousFilterText?.length === textValue?.length - 1 && key === textValue.slice(-1) ) { return textValue; @@ -434,15 +435,15 @@ export const FilterableSelect = React.forwardRef( useEffect(() => { const textStartsWithFilter = textValue - .toLowerCase() - .startsWith(filterText.toLowerCase()); + ?.toLowerCase() + .startsWith(filterText?.toLowerCase()); const isTextboxActive = !disabled && !readOnly; if ( isTextboxActive && textboxRef && - filterText.length && - textValue.length > filterText.length && + filterText?.length && + textValue?.length > filterText?.length && textStartsWithFilter ) { textboxRef.selectionStart = filterText.length; @@ -450,12 +451,13 @@ export const FilterableSelect = React.forwardRef( }, [textValue, filterText, textboxRef, disabled, readOnly]); const onSelectOption = useCallback( - (optionData) => { + (optionData: OptionData) => { const { id: selectedOptionId, text, value: newValue, selectionType, + selectionConfirmed, } = optionData; if (selectionType === "tab") { @@ -470,8 +472,8 @@ export const FilterableSelect = React.forwardRef( setHighlightedValue(newValue); } - setTextValue(text); - triggerChange(newValue); + setTextValue(text || /* istanbul ignore next */ ""); + triggerChange(newValue, selectionConfirmed); setActiveDescendantId(selectedOptionId); if (selectionType !== "navigationKey") { diff --git a/src/components/select/filterable-select/filterable-select.spec.tsx b/src/components/select/filterable-select/filterable-select.spec.tsx index de637a3375..8d6751f1fa 100644 --- a/src/components/select/filterable-select/filterable-select.spec.tsx +++ b/src/components/select/filterable-select/filterable-select.spec.tsx @@ -75,6 +75,22 @@ describe("FilterableSelect", () => { expect(wrapper.find(Textbox).prop("type")).toBe("text"); }); + it("should not throw when non-matching filter text is input and enter key pressed", () => { + const wrapper = renderSelect({}); + + expect(() => { + act(() => { + wrapper.find(Textbox).prop("onChange")?.(({ + target: { value: "foo" }, + nativeEvent: { inputType: undefined }, + } as unknown) as React.ChangeEvent); + wrapper.find(Textbox).prop("onKeyDown")?.({ + key: "Enter", + } as React.KeyboardEvent); + }); + }).not.toThrow(); + }); + describe("with a ref", () => { it("the input ref should be forwarded", () => { let mockRef = { current: null }; @@ -579,23 +595,27 @@ describe("FilterableSelect", () => { value: "Foo", text: "Bar", selectionType: "navigationKey", + selectionConfirmed: false, }; const clickOptionObject = { value: "Foo", text: "Bar", selectionType: "click", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: "Foo", }, }; const expectedDeleteEventObject = { + selectionConfirmed: false, target: { ...textboxProps, value: "", @@ -880,6 +900,7 @@ describe("FilterableSelect", () => { value: "opt3", text: "black", selectionType: "click", + selectionConfirmed: true, }; beforeEach(() => { @@ -900,7 +921,10 @@ describe("FilterableSelect", () => { act(() => { wrapper.find(SelectList).prop("onSelect")(clickOptionObject); }); - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: true, + ...expectedObject, + }); }); }); @@ -912,7 +936,10 @@ describe("FilterableSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); describe("and an an empty value has been passed", () => { @@ -931,7 +958,10 @@ describe("FilterableSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); it("then the Textbox visible value should be changed to that character", () => { diff --git a/src/components/select/filterable-select/filterable-select.stories.mdx b/src/components/select/filterable-select/filterable-select.stories.mdx index d19e0688ae..63c4047edd 100644 --- a/src/components/select/filterable-select/filterable-select.stories.mdx +++ b/src/components/select/filterable-select/filterable-select.stories.mdx @@ -167,6 +167,16 @@ be customised if desired using the `virtualScrollOverscan` prop. Higher values w +### Selection confirmed + +A change event is emitted each time an option is navigated via keyboard as it sets the value of the +Select input. For those that need to trigger further actions when the user makes a selection, there is +a `selectionConfirmed` property on the emitted event when the enter key is pressed or an option is clicked. + + + + + ## Props ### Filterable Select diff --git a/src/components/select/filterable-select/filterable-select.stories.tsx b/src/components/select/filterable-select/filterable-select.stories.tsx index fa7d84b8c8..2156c13099 100644 --- a/src/components/select/filterable-select/filterable-select.stories.tsx +++ b/src/components/select/filterable-select/filterable-select.stories.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef } from "react"; -import { FilterableSelect, Option, OptionRow } from ".."; +import { + CustomSelectChangeEvent, + FilterableSelect, + Option, + OptionRow, +} from ".."; import Button from "../../button"; import Dialog from "../../dialog"; import CarbonProvider from "../../carbon-provider"; import Box from "../../box"; +import Icon from "../../icon"; +import Typography from "../../typography"; export const Default = () => ( @@ -708,3 +715,41 @@ export const Virtualised = () => { ); }; + +export const SelectionConfirmedStory = () => { + const [selectionConfirmed, setSelectionConfirmed] = useState(false); + return ( + + + Selection Confirmed:{" "} + {selectionConfirmed ? ( + + ) : ( + + )} + + { + setSelectionConfirmed(!!ev.selectionConfirmed); + }} + name="selection confirmed" + id="selection confirmed" + label="color" + > + + + + + + + + + + + + + + ); +}; + +SelectionConfirmedStory.parameters = { chromatic: { disableSnapshot: true } }; diff --git a/src/components/select/index.ts b/src/components/select/index.ts index e19fece48e..b1203823ce 100644 --- a/src/components/select/index.ts +++ b/src/components/select/index.ts @@ -5,7 +5,10 @@ export type { OptionRowProps } from "./option-row"; export { default as OptionGroupHeader } from "./option-group-header"; export type { OptionGroupHeaderProps } from "./option-group-header"; export { default as Select } from "./simple-select"; -export type { SimpleSelectProps } from "./simple-select"; +export type { + SimpleSelectProps, + CustomSelectChangeEvent, +} from "./simple-select"; export { default as FilterableSelect } from "./filterable-select"; export type { FilterableSelectProps } from "./filterable-select"; export { default as MultiSelect } from "./multi-select"; diff --git a/src/components/select/multi-select/multi-select-test.stories.tsx b/src/components/select/multi-select/multi-select-test.stories.tsx index 4eefd7d711..51a6a44fc3 100644 --- a/src/components/select/multi-select/multi-select-test.stories.tsx +++ b/src/components/select/multi-select/multi-select-test.stories.tsx @@ -1,5 +1,10 @@ -import React, { useState } from "react"; -import { MultiSelect, Option, MultiSelectProps } from ".."; +import React, { useState, useRef } from "react"; +import { + MultiSelect, + Option, + MultiSelectProps, + CustomSelectChangeEvent, +} from ".."; import partialAction from "../../../../.storybook/utils/partial-action"; import OptionRow from "../option-row/option-row.component"; import Button from "../../button/button.component"; @@ -10,6 +15,7 @@ import CarbonProvider from "../../carbon-provider/carbon-provider.component"; export default { component: MultiSelect, title: "Select/MultiSelect/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -154,7 +160,7 @@ export const MultiSelectLongPillComponent = ( export const MultiSelectWithLazyLoadingComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); + const preventLoading = useRef(false); const [value, setValue] = useState([]); const [isLoading, setIsLoading] = useState(true); const asyncList = [ @@ -200,9 +206,9 @@ export const MultiSelectWithLazyLoadingComponent = ( export const MultiSelectLazyLoadTwiceComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); + const preventLoading = useRef(false); const [value, setValue] = useState([]); - const [isLoading, setIsLoading] = React.useState(true); + const [isLoading, setIsLoading] = useState(true); const asyncList = [ , , @@ -258,7 +264,7 @@ export const MultiSelectObjectAsValueComponent = ( { id: "Green", value: 5, text: "Green" }, ]); - const optionList = React.useRef([ + const optionList = useRef([ { ); }; + +export const SelectionConfirmed = () => { + const [value, setValue] = useState([]); + const [confirmedSelections, setConfirmedSelections] = useState([]); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue((event.target.value as unknown) as string[]); + if (event.selectionConfirmed) { + setConfirmedSelections((event.target.value as unknown) as string[]); + } + }; + return ( + <> + + + + + + + + + + + + + + {confirmedSelections.map((cs) => ( + {cs} + ))} + + > + ); +}; diff --git a/src/components/select/multi-select/multi-select.component.tsx b/src/components/select/multi-select/multi-select.component.tsx index 90d967a107..d677ff8027 100644 --- a/src/components/select/multi-select/multi-select.component.tsx +++ b/src/components/select/multi-select/multi-select.component.tsx @@ -28,6 +28,7 @@ import useFormSpacing from "../../../hooks/__internal__/useFormSpacing"; import useInputAccessibility from "../../../hooks/__internal__/useInputAccessibility/useInputAccessibility"; import { OptionProps } from "../option"; import { OptionRowProps } from "../option-row"; +import { CustomSelectChangeEvent } from "../simple-select"; let deprecateInputRefWarnTriggered = false; let deprecateUncontrolledWarnTriggered = false; @@ -191,16 +192,17 @@ export const MultiSelect = React.forwardRef( }, [onOpen]); const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); @@ -213,14 +215,15 @@ export const MultiSelect = React.forwardRef( ( updateFunction: ( previousValue: string[] | Record[] - ) => string[] | Record[] + ) => string[] | Record[], + selectionConfirmed ) => { const newValue = updateFunction( actualValue as string[] | Record[] ); // only call onChange if an option has been selected or deselected if (onChange && newValue.length !== actualValue?.length) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } // no need to update selectedValue if the component is controlled: onChange should take care of updating the value @@ -270,7 +273,7 @@ export const MultiSelect = React.forwardRef( newValue.splice(index, 1); return newValue; - }); + }, true); }, [updateValue] ); @@ -357,6 +360,11 @@ export const MultiSelect = React.forwardRef( let pillProps: Omit = {}; + if (!matchingOption) { + return null; + } + + /* istanbul ignore else */ if (React.isValidElement(matchingOption)) { pillProps = { title: matchingOption.props.text, @@ -365,12 +373,12 @@ export const MultiSelect = React.forwardRef( }; } - const title = pillProps.title || ""; + const title = pillProps.title || /* istanbul ignore next */ ""; const key = title + ((React.isValidElement(matchingOption) && (matchingOption.props as OptionProps | OptionRowProps).value) || - index); + /* istanbul ignore next */ index); return ( @@ -534,6 +542,7 @@ export const MultiSelect = React.forwardRef( value: newValue, selectionType, id: selectedOptionId, + selectionConfirmed, } = optionData; if (selectionType === "navigationKey") { @@ -561,7 +570,7 @@ export const MultiSelect = React.forwardRef( } return [...previousValue, newValue]; - }); + }, selectionConfirmed); }, [textboxRef, actualValue, updateValue] ); diff --git a/src/components/select/multi-select/multi-select.spec.tsx b/src/components/select/multi-select/multi-select.spec.tsx index 7c1d61067b..2f658c9d59 100644 --- a/src/components/select/multi-select/multi-select.spec.tsx +++ b/src/components/select/multi-select/multi-select.spec.tsx @@ -77,6 +77,21 @@ describe("MultiSelect", () => { ).toBe(placeholder); }); + it("should not render an empty Pill when non-matching filter text is input and enter key pressed", () => { + const wrapper = renderSelect({}); + + act(() => { + wrapper.find(Textbox).prop("onChange")?.({ + target: { value: "foo" }, + } as React.ChangeEvent); + wrapper.find(Textbox).prop("onKeyDown")?.({ + key: "Enter", + } as React.KeyboardEvent); + }); + + expect(wrapper.find(Pill).exists()).toBe(false); + }); + describe("when an HTML element is clicked", () => { let wrapper: ReactWrapper; let domNode: HTMLElement; @@ -484,6 +499,7 @@ describe("MultiSelect", () => { value: "opt3", text: "blue", selectionType: "enter", + selectionConfirmed: true, }; const changeEventObject = { target: { value: "b" } }; @@ -749,17 +765,20 @@ describe("MultiSelect", () => { value: "opt1", text: "red", selectionType: "enter", + selectionConfirmed: true, }; const mockNavigationKeyOptionObject = { value: "opt1", text: "red", selectionType: "navigationKey", + selectionConfirmed: false, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: ["opt1"], @@ -958,6 +977,7 @@ describe("MultiSelect", () => { describe("when the component is controlled", () => { const expectedObject = { + selectionConfirmed: true, target: { id: "testSelect", name: "testSelect", @@ -969,6 +989,7 @@ describe("MultiSelect", () => { value: "opt2", text: "black", selectionType: "click", + selectionConfirmed: true, }; describe("and an option is selected", () => { @@ -1069,12 +1090,14 @@ describe("MultiSelect", () => { value: "opt1", text: "red", selectionType: "enter", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: ["opt1"], diff --git a/src/components/select/option/option.component.tsx b/src/components/select/option/option.component.tsx index f17b7f2a7d..56ced45bb2 100644 --- a/src/components/select/option/option.component.tsx +++ b/src/components/select/option/option.component.tsx @@ -88,7 +88,7 @@ const Option = React.forwardRef( role="option" hidden={hidden} style={style} - {...rest} + {...{ ...rest, fill: undefined }} > {children || text} diff --git a/src/components/select/select-list/select-list.component.tsx b/src/components/select/select-list/select-list.component.tsx index 1d8c86b1af..0ea42b59bb 100644 --- a/src/components/select/select-list/select-list.component.tsx +++ b/src/components/select/select-list/select-list.component.tsx @@ -48,6 +48,7 @@ export interface SelectListProps { value?: string | Record; id?: string; selectionType: string; + selectionConfirmed: boolean; }) => void; /** A callback for when the list should be closed */ onSelectListClose: () => void; @@ -185,7 +186,11 @@ const SelectList = React.forwardRef( const handleSelect = useCallback( (optionData) => { - onSelect({ ...optionData, selectionType: "click" }); + onSelect({ + ...optionData, + selectionType: "click", + selectionConfirmed: true, + }); }, [onSelect] ); @@ -329,6 +334,7 @@ const SelectList = React.forwardRef( id: childIds ? childIds[nextIndex] : /* istanbul ignore next */ undefined, + selectionConfirmed: false, }); }, [ @@ -345,7 +351,7 @@ const SelectList = React.forwardRef( const handleActionButtonTab = useCallback( (event, isActionButtonFocused) => { if (isActionButtonFocused) { - onSelect({ selectionType: "tab" }); + onSelect({ selectionType: "tab", selectionConfirmed: false }); } else { event.preventDefault(); listActionButtonRef.current?.focus(); @@ -393,6 +399,7 @@ const SelectList = React.forwardRef( text, value, selectionType: "enterKey", + selectionConfirmed: true, }); } else if (isNavigationKey(key)) { focusOnAnchor(); diff --git a/src/components/select/select-list/select-list.spec.tsx b/src/components/select/select-list/select-list.spec.tsx index 58409ca289..1591f93686 100644 --- a/src/components/select/select-list/select-list.spec.tsx +++ b/src/components/select/select-list/select-list.spec.tsx @@ -346,6 +346,7 @@ describe("SelectList", () => { selectionType: "enterKey", text: "blue", value: "opt3", + selectionConfirmed: true, }); }); }); @@ -363,6 +364,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); @@ -380,6 +382,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); @@ -410,6 +413,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -427,6 +431,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -443,6 +448,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); @@ -458,6 +464,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "blue", value: "opt3", + selectionConfirmed: false, }); }); }); @@ -498,6 +505,7 @@ describe("SelectList", () => { selectionType: "click", text: "red", value: "opt1", + selectionConfirmed: true, }); }); }); @@ -771,7 +779,10 @@ describe("SelectList", () => { const testContainer = document.createElement("div"); const onFocusFn = jest.fn(); const onSelectFn = jest.fn(); - const expectedSelectValue = { selectionType: "tab" }; + const expectedSelectValue = { + selectionType: "tab", + selectionConfirmed: false, + }; document.body.appendChild(testContainer); @@ -915,6 +926,7 @@ describe("SelectList", () => { selectionType: "navigationKey", text: "red", value: "opt1", + selectionConfirmed: false, }); }); }); diff --git a/src/components/select/select-textbox/select-textbox.component.tsx b/src/components/select/select-textbox/select-textbox.component.tsx index 8f86fa400c..dd6cc5e2c9 100644 --- a/src/components/select/select-textbox/select-textbox.component.tsx +++ b/src/components/select/select-textbox/select-textbox.component.tsx @@ -6,6 +6,7 @@ import Textbox, { CommonTextboxProps } from "../../textbox"; import SelectText from "../__internal__/select-text"; import useLocale from "../../../hooks/__internal__/useLocale"; import { ValidationProps } from "../../../__internal__/validations"; +import { CustomSelectChangeEvent } from "../simple-select/simple-select.component"; const floatingMiddleware = [ offset(({ rects }) => ({ @@ -21,7 +22,7 @@ const floatingMiddleware = [ export interface FormInputPropTypes extends ValidationProps, - Omit { + Omit { /** Breakpoint for adaptive label (inline labels change to top aligned). Enables the adaptive behaviour when set */ adaptiveLabelBreakpoint?: number; /** Prop to specify the aria-label attribute of the component input */ @@ -49,7 +50,9 @@ export interface FormInputPropTypes /** Specify a callback triggered on blur */ onBlur?: (ev: React.FocusEvent) => void; /** Specify a callback triggered on change */ - onChange?: (ev: React.ChangeEvent) => void; + onChange?: ( + ev: CustomSelectChangeEvent | React.ChangeEvent + ) => void; /** Specify a callback triggered on click */ onClick?: (ev: React.MouseEvent) => void; /** Specify a callback triggered on focus */ diff --git a/src/components/select/simple-select/index.ts b/src/components/select/simple-select/index.ts index ab19d3d035..97a1553a58 100644 --- a/src/components/select/simple-select/index.ts +++ b/src/components/select/simple-select/index.ts @@ -1,2 +1,5 @@ export { default } from "./simple-select.component"; -export type { SimpleSelectProps } from "./simple-select.component"; +export type { + SimpleSelectProps, + CustomSelectChangeEvent, +} from "./simple-select.component"; diff --git a/src/components/select/simple-select/simple-select-test.stories.tsx b/src/components/select/simple-select/simple-select-test.stories.tsx index 5bc5935fcc..b8b278efd6 100644 --- a/src/components/select/simple-select/simple-select-test.stories.tsx +++ b/src/components/select/simple-select/simple-select-test.stories.tsx @@ -1,7 +1,10 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import Typography from "../../../components/typography"; import Content from "../../../components/content"; -import { Select as SimpleSelect } from "../../../../src/components/select"; +import { + CustomSelectChangeEvent, + Select as SimpleSelect, +} from "../../../../src/components/select"; import OptionRow from "../option-row/option-row.component"; import OptionGroupHeader from "../option-group-header/option-group-header.component"; import Box from "../../box"; @@ -12,6 +15,7 @@ import { Select, Option, SimpleSelectProps } from ".."; export default { component: Select, title: "Select/Test", + excludeStories: ["SelectionConfirmed"], parameters: { info: { disable: true }, chromatic: { @@ -169,7 +173,7 @@ export const DelayedReposition = () => { DelayedReposition.storyName = "delayed reposition"; export const SimpleSelectComponent = (props: Partial) => { - const [value, setValue] = React.useState(""); + const [value, setValue] = useState(""); function onChangeHandler(event: React.ChangeEvent) { setValue(event.target.value); @@ -204,9 +208,9 @@ export const SimpleSelectComponent = (props: Partial) => { export const SimpleSelectWithLazyLoadingComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); - const [value, setValue] = React.useState("black"); - const [isLoading, setIsLoading] = React.useState(true); + const preventLoading = useRef(false); + const [value, setValue] = useState("black"); + const [isLoading, setIsLoading] = useState(true); const asyncList = [ , , @@ -214,7 +218,7 @@ export const SimpleSelectWithLazyLoadingComponent = ( , , ]; - const [optionList, setOptionList] = React.useState([ + const [optionList, setOptionList] = useState([ , ]); @@ -254,11 +258,11 @@ export const SimpleSelectWithLazyLoadingComponent = ( export const SimpleSelectWithInfiniteScrollComponent = ( props: Partial ) => { - const preventLoading = React.useRef(false); - const preventLazyLoading = React.useRef(false); - const lazyLoadingCounter = React.useRef(0); - const [value, setValue] = React.useState(""); - const [isLoading, setIsLoading] = React.useState(true); + const preventLoading = useRef(false); + const preventLazyLoading = useRef(false); + const lazyLoadingCounter = useRef(0); + const [value, setValue] = useState(""); + const [isLoading, setIsLoading] = useState(true); const asyncList = [ , , @@ -349,7 +353,7 @@ export const SimpleSelectObjectAsValueComponent = ( value: 5, text: "Green", }); - const optionList = React.useRef([ + const optionList = useRef([ { + const [value, setValue] = useState(""); + const [confirmedSelection, setConfirmedSelection] = useState(""); + + const handleChange = (event: CustomSelectChangeEvent) => { + setValue(event.target.value); + if (event.selectionConfirmed) { + setConfirmedSelection(event.target.value); + } + }; + return ( + <> + + + + + + + + + + + + + {confirmedSelection ? ( + + {confirmedSelection} + + ) : null} + > + ); +}; diff --git a/src/components/select/simple-select/simple-select.component.tsx b/src/components/select/simple-select/simple-select.component.tsx index 0df95e1eb7..2d2bb1f603 100644 --- a/src/components/select/simple-select/simple-select.component.tsx +++ b/src/components/select/simple-select/simple-select.component.tsx @@ -25,6 +25,19 @@ let deprecateUncontrolledWarnTriggered = false; type TimerId = ReturnType; +export interface OptionData { + text?: string; + value?: string | Record; + id?: string; + selectionType: string; + selectionConfirmed?: boolean; +} + +export interface CustomSelectChangeEvent + extends React.ChangeEvent { + selectionConfirmed?: boolean; +} + export interface SimpleSelectProps extends Omit { /** Prop to specify the aria-label attribute of the component input */ @@ -164,16 +177,17 @@ export const SimpleSelect = React.forwardRef( ]) as React.ReactElement[]; const createCustomEvent = useCallback( - (newValue) => { + (newValue, selectionConfirmed = false) => { const customEvent = { target: { ...(name && { name }), ...(id && { id }), value: newValue, }, + selectionConfirmed, }; - return customEvent as React.ChangeEvent; + return customEvent as CustomSelectChangeEvent; }, [name, id] ); @@ -406,7 +420,8 @@ export const SimpleSelect = React.forwardRef( function updateValue( newValue?: string | Record, - text?: string + text?: string, + selectionConfirmed?: boolean ) { if (!isControlled.current) { setSelectedValue(newValue); @@ -414,25 +429,21 @@ export const SimpleSelect = React.forwardRef( } if (onChange) { - onChange(createCustomEvent(newValue)); + onChange(createCustomEvent(newValue, selectionConfirmed)); } } - function onSelectOption(optionData: { - text?: string; - value?: string | Record; - id?: string; - selectionType: string; - }) { + function onSelectOption(optionData: OptionData) { const { text, value: newValue, selectionType, id: selectedOptionId, + selectionConfirmed, } = optionData; const isClickTriggered = selectionType === "click"; - updateValue(newValue, text); + updateValue(newValue, text, selectionConfirmed); setActiveDescendantId(selectedOptionId); if (selectionType !== "navigationKey") { diff --git a/src/components/select/simple-select/simple-select.spec.tsx b/src/components/select/simple-select/simple-select.spec.tsx index 8101b7974c..999965f1a2 100644 --- a/src/components/select/simple-select/simple-select.spec.tsx +++ b/src/components/select/simple-select/simple-select.spec.tsx @@ -700,6 +700,7 @@ describe("SimpleSelect", () => { id: "testId", }; const mockEventObject = { + selectionConfirmed: false, target: { ...textboxProps, value: "opt3", @@ -720,17 +721,20 @@ describe("SimpleSelect", () => { value: "opt2", text: "green", selectionType: "navigationKey", + selectionConfirmed: false, }; const clickOptionObject = { value: "opt2", text: "green", selectionType: "click", + selectionConfirmed: true, }; const textboxProps = { name: "testName", id: "testId", }; const expectedEventObject = { + selectionConfirmed: true, target: { ...textboxProps, value: "opt2", @@ -894,6 +898,7 @@ describe("SimpleSelect", () => { value: "opt3", text: "black", selectionType: "click", + selectionConfirmed: true, }; beforeEach(() => { @@ -910,7 +915,10 @@ describe("SimpleSelect", () => { act(() => { wrapper.find(SelectList).prop("onSelect")(clickOptionObject); }); - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: true, + ...expectedObject, + }); }); }); @@ -925,7 +933,10 @@ describe("SimpleSelect", () => { }); it("then the onChange function should have been called with with the expected value", () => { - expect(onChangeFn).toHaveBeenCalledWith(expectedObject); + expect(onChangeFn).toHaveBeenCalledWith({ + selectionConfirmed: false, + ...expectedObject, + }); }); }); diff --git a/src/components/select/simple-select/simple-select.stories.mdx b/src/components/select/simple-select/simple-select.stories.mdx index d7bed0946e..8787c6d9ce 100644 --- a/src/components/select/simple-select/simple-select.stories.mdx +++ b/src/components/select/simple-select/simple-select.stories.mdx @@ -192,6 +192,17 @@ be customised if desired using the `virtualScrollOverscan` prop. Higher values w +### Selection confirmed + +A change event is emitted each time an option is navigated via keyboard as it sets the value of the +Select input. For those that need to trigger further actions when the user makes a selection, there is +a `selectionConfirmed` property on the emitted event when the enter key is pressed or an option is clicked. + + + + + + ## Props ### Select diff --git a/src/components/select/simple-select/simple-select.stories.tsx b/src/components/select/simple-select/simple-select.stories.tsx index fc8a2486bd..ccd6dbf1a0 100644 --- a/src/components/select/simple-select/simple-select.stories.tsx +++ b/src/components/select/simple-select/simple-select.stories.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef } from "react"; -import { Select, Option, OptionRow, OptionGroupHeader } from ".."; +import { + Select, + Option, + OptionRow, + OptionGroupHeader, + CustomSelectChangeEvent, +} from ".."; import Button from "../../button"; import Icon from "../../icon"; import CarbonProvider from "../../carbon-provider"; import Box from "../../box"; +import Typography from "../../typography"; export const Default = () => ( @@ -697,3 +704,41 @@ export const WithMultipleColumnsAndVirtualisation = () => ( WithMultipleColumnsAndVirtualisation.parameters = { chromatic: { disableSnapshot: true }, }; + +export const SelectionConfirmedStory = () => { + const [selectionConfirmed, setSelectionConfirmed] = useState(false); + return ( + + + Selection Confirmed:{" "} + {selectionConfirmed ? ( + + ) : ( + + )} + + { + setSelectionConfirmed(!!ev.selectionConfirmed); + }} + name="selection confirmed" + id="selection confirmed" + label="color" + > + + + + + + + + + + + + + + ); +}; + +SelectionConfirmedStory.parameters = { chromatic: { disableSnapshot: true } }; diff --git a/src/components/textbox/components.test-pw.tsx b/src/components/textbox/components.test-pw.tsx new file mode 100644 index 0000000000..c73851ec66 --- /dev/null +++ b/src/components/textbox/components.test-pw.tsx @@ -0,0 +1,224 @@ +import React, { useState, useRef } from "react"; +import Textbox, { TextboxProps } from "."; +import Box from "../box"; +import Button from "../button"; +import CarbonProvider from "../carbon-provider/carbon-provider.component"; + +export const SIZES = ["small", "medium", "large"] as const; +export const VALIDATIONS = ["error", "warning", "info"] as const; + +export const TextboxComponent = (props: Partial) => { + const [state, setState] = useState("Textbox"); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + + ); +}; + +export const TextboxComponentInputRef = () => { + const ref = useRef(null); + + return ( + + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (ref.current) ref.current.focus(); + }} + > + Focus Textbox + + ) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ref.current = el.current; + }} + /> + + ); +}; + +export const TextboxComponentWithLeftChildren = () => { + const [state, setState] = useState("Textbox"); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + Test} + onChange={setValue} + /> + ); +}; + +export const TextboxComponentWithPositionedChildren = () => { + const [state, setState] = useState("Textbox"); + const setValue = ({ target }: React.ChangeEvent) => { + setState(target.value); + }; + return ( + Test} + onChange={setValue} + /> + ); +}; + +export const TextboxValidationsAsAStringWithTooltipDefault = () => { + return ( + + {VALIDATIONS.map((validationType) => ( + + + + ))} + + ); +}; + +export const TextboxValidationsAsABoolean = () => { + return ( + + {VALIDATIONS.map((validationType) => ( + + + + + ))} + + ); +}; + +export const TextboxValidationsAsAString = () => { + return ( + + {VALIDATIONS.map((validationType) => ( + + + + + ))} + + ); +}; + +export const TextboxValidationsAsAStringWithTooltipCustom = () => { + return ( + + {VALIDATIONS.map((validationType) => ( + + + + ))} + + ); +}; + +export const TextboxValidationsAsAStringDisplayedOnLabel = () => { + return ( + + {VALIDATIONS.map((validationType) => ( + + + + + ))} + + ); +}; + +export const TextboxNewDesignsValidation = () => { + return ( + + + {(["error", "warning"] as const).map((validationType) => + SIZES.map((size) => ( + + + + + )) + )} + + + ); +}; diff --git a/src/components/textbox/textbox-test.stories.tsx b/src/components/textbox/textbox-test.stories.tsx index 17ba005373..1d37d57808 100644 --- a/src/components/textbox/textbox-test.stories.tsx +++ b/src/components/textbox/textbox-test.stories.tsx @@ -1,8 +1,6 @@ -import React, { useState, useRef } from "react"; +import React, { useState } from "react"; import { action } from "@storybook/addon-actions"; import Textbox, { TextboxProps } from "."; -import Box from "../box"; -import Button from "../button"; import CarbonProvider from "../carbon-provider/carbon-provider.component"; import { ICONS } from "../icon/icon-config"; @@ -193,42 +191,3 @@ export const PrefixWithSizes = () => { }; PrefixWithSizes.storyName = "prefix with sizes"; - -export const TextboxComponent = (props: Partial) => { - const [state, setState] = useState("Textbox"); - - const setValue = ({ target }: React.ChangeEvent) => { - setState(target.value); - }; - - return ( - - ); -}; - -export const TextboxComponentInputRef = () => { - const ref = useRef(null); - - return ( - - { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (ref.current) ref.current.focus(); - }} - > - Focus Textbox - - ) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - ref.current = el.current; - }} - /> - - ); -}; diff --git a/src/components/textbox/textbox.pw.tsx b/src/components/textbox/textbox.pw.tsx new file mode 100644 index 0000000000..f61d7c7a39 --- /dev/null +++ b/src/components/textbox/textbox.pw.tsx @@ -0,0 +1,1110 @@ +import { expect, test } from "@playwright/experimental-ct-react17"; +import { TextboxProps } from "components/textbox"; +import React from "react"; +import { HooksConfig } from "../../../playwright"; +import { + getDesignTokensByCssProperty, + checkAccessibility, + checkGoldenOutline, + verifyRequiredAsteriskForLabel, + getStyle, + assertCssValueIsApproximately, +} from "../../../playwright/support/helper"; +import { + fieldHelpPreview, + getDataElementByValue, + getDataComponentByValue, + getDataRoleByValue, + tooltipPreview, + characterLimit, +} from "../../../playwright/components"; +import { + TextboxComponent, + TextboxComponentInputRef, + TextboxComponentWithLeftChildren, + TextboxComponentWithPositionedChildren, + TextboxValidationsAsAStringWithTooltipDefault, + TextboxValidationsAsABoolean, + TextboxValidationsAsAString, + TextboxValidationsAsAStringWithTooltipCustom, + TextboxValidationsAsAStringDisplayedOnLabel, + TextboxNewDesignsValidation, +} from "./components.test-pw"; +import { + textbox, + textboxInput, + textboxPrefix, +} from "../../../playwright/components/textbox"; + +import { + CHARACTERS, + SIZE, + VALIDATION, +} from "../../../playwright/support/constants"; + +import { BUTTON, ICON } from "../../../playwright/components/locators"; +import Box from "../../../src/components/box"; + +const testData = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS]; + +const keysToTrigger = ["Enter", "Space"] as const; + +test.describe("Prop checks for Textbox component", () => { + ([ + [SIZE.SMALL, "32px", "--sizing400"], + [SIZE.MEDIUM, "40px", "--sizing500"], + [SIZE.LARGE, "48px", "--sizing600"], + ] as [TextboxProps["size"], string, string][]).forEach( + ([size, height, token]) => { + test(`should render with ${size} size and ${height} height when size prop is ${size}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(textbox(page)).toHaveCSS("min-height", height); + + const tokenValues = await getDesignTokensByCssProperty( + page, + textbox(page), + "min-height" + ); + expect(tokenValues[0]).toBe(token); + }); + } + ); + + [ + ["background", "--colorsUtilityYang100"], + ["border", "--colorsUtilityMajor300"], + ].forEach(([cssProp, token]) => { + test(`should render with correct ${cssProp} token`, async ({ + mount, + page, + }) => { + await mount(); + + const tokenValues = await getDesignTokensByCssProperty( + page, + textbox(page), + cssProp + ); + expect(tokenValues[0]).toBe(token); + }); + }); + + test("should render with isOptional prop", async ({ mount, page }) => { + await mount(); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + const contentValue = await getStyle(labelParent, "content", ":after"); + + expect(contentValue).toContain("(optional)"); + }); + + test("should render with data-component prop", async ({ mount, page }) => { + await mount(); + + await expect( + getDataComponentByValue(page, CHARACTERS.STANDARD) + ).toBeVisible(); + }); + + test("should render with data-element prop", async ({ mount, page }) => { + await mount(); + + await expect( + getDataElementByValue(page, CHARACTERS.STANDARD) + ).toBeVisible(); + }); + + test("should render with data-role prop", async ({ mount, page }) => { + await mount(); + + await expect(getDataRoleByValue(page, CHARACTERS.STANDARD)).toBeVisible(); + }); + + test("should render with id prop", async ({ mount, page }) => { + await mount(); + + await expect(textboxInput(page)).toHaveId(CHARACTERS.STANDARD); + }); + + testData.forEach((labelVals) => { + test(`should render with label prop ${labelVals}`, async ({ + mount, + page, + }) => { + await mount(); + + const label = getDataElementByValue(page, "label"); + + await expect(label).toHaveText(labelVals); + }); + }); + + test("should render with labelInline prop", async ({ mount, page }) => { + await mount(); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + await expect(labelParent).toHaveCSS("justify-content", "flex-end"); + }); + + ([ + ["left", "start"], + ["right", "end"], + ] as [TextboxProps["labelAlign"], string][]).forEach( + ([labelAlign, cssValue]) => { + test(`should render with ${labelAlign} labelAlign prop`, async ({ + mount, + page, + }) => { + await mount(); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + await expect(labelParent).toHaveCSS( + "justify-content", + `flex-${cssValue}` + ); + }); + } + ); + + testData.forEach((labelHelp) => { + test(`should render with labelHelp prop ${labelHelp}`, async ({ + mount, + page, + }) => { + await mount(); + + const question = getDataElementByValue(page, "question"); + await question.click(); + + await expect(tooltipPreview(page)).toHaveText(labelHelp); + }); + }); + + ([ + [1, "8px"], + [2, "16px"], + ] as [TextboxProps["labelSpacing"], string][]).forEach( + ([spacing, padding]) => { + test(`should render with labelSpacing prop ${spacing}`, async ({ + mount, + page, + }) => { + await mount(); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + await expect(labelParent).toHaveCSS("padding-right", padding); + }); + } + ); + + ([ + [10, 90, 135, 1229], + [30, 70, 409, 956], + [80, 20, 1092, 273], + ] as [ + TextboxProps["labelWidth"], + TextboxProps["inputWidth"], + number, + number + ][]).forEach(([labelVal, inputVal, labelRatio, inputRatio]) => { + test(`should render with correct ratios when inputWidth prop is ${inputVal} and labelWidth prop is ${labelVal}`, async ({ + mount, + page, + }) => { + await mount( + + ); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + const inputParent = getDataElementByValue(page, "input").locator(".."); + + await assertCssValueIsApproximately(labelParent, "width", labelRatio); + await assertCssValueIsApproximately(inputParent, "width", inputRatio); + }); + }); + + [ + ["foo", "with"], + ["", "without"], + ].forEach(([hint, renderStatus]) => { + test(`should render ${renderStatus} an input hint depending on the inputHint prop value`, async ({ + mount, + page, + }) => { + await mount(); + + const inputHint = getDataElementByValue(page, "input-hint"); + + if (renderStatus === "with") { + await expect(inputHint).toHaveCount(1); + } else { + await expect(inputHint).toHaveCount(0); + } + }); + }); + + ([ + [4, "with", "is"], + ["", "without", "is not"], + ] as [TextboxProps["characterLimit"], string, string][]).forEach( + ([characterLimitVal, renderStatus, characterLimitStatus]) => { + test(`should render ${renderStatus} a character count when the characterLimit prop ${characterLimitStatus} passed`, async ({ + mount, + page, + }) => { + await mount(); + + const characterCount = getDataElementByValue(page, "character-count"); + + if (renderStatus === "with") { + await expect(characterCount).toHaveCount(1); + } else { + await expect(characterCount).toHaveCount(0); + } + }); + } + ); + + ([ + [4, "with", "is"], + ["", "without", "is not"], + ] as [TextboxProps["characterLimit"], string, string][]).forEach( + ([characterLimitVal, renderStatus, characterLimitStatus]) => { + test(`should render ${renderStatus} a visually hidden character count when the characterLimit prop ${characterLimitStatus} passed`, async ({ + mount, + page, + }) => { + await mount(); + + const visuallyHiddenCharacterCount = getDataElementByValue( + page, + "visually-hidden-character-count" + ); + + if (renderStatus === "with") { + await expect(visuallyHiddenCharacterCount).toHaveCount(1); + } else { + await expect(visuallyHiddenCharacterCount).toHaveCount(0); + } + }); + } + ); + + ([ + [4, "with", "is"], + ["", "without", "is not"], + ] as [TextboxProps["characterLimit"], string, string][]).forEach( + ([characterLimitVal, renderStatus, characterLimitStatus]) => { + test(`should render ${renderStatus} a visually hidden character count hint when the characterLimit prop ${characterLimitStatus} passed`, async ({ + mount, + page, + }) => { + await mount(); + + const visuallyHiddenCharacterCountHint = getDataElementByValue( + page, + "visually-hidden-hint" + ); + + if (renderStatus === "with") { + await expect(visuallyHiddenCharacterCountHint).toHaveCount(1); + } else { + await expect(visuallyHiddenCharacterCountHint).toHaveCount(0); + } + }); + } + ); + + ["10%", "30%", "50%", "80%", "100%"].forEach((maxWidth) => { + test(`should render with ${maxWidth} max-width`, async ({ + mount, + page, + }) => { + await mount(); + + const textboxParent = textbox(page).locator(".."); + await expect(textboxParent).toHaveCSS("max-width", maxWidth); + }); + }); + + test("should render with max-width of 100%, when maxWidth prop is not passed", async ({ + mount, + page, + }) => { + await mount(); + + const textboxParent = textbox(page).locator(".."); + await expect(textboxParent).toHaveCSS("max-width", "100%"); + }); + + test("should render with required prop", async ({ mount, page }) => { + await mount(); + + await verifyRequiredAsteriskForLabel(page); + }); + + [5, 10, 11, 15, 20].forEach((limit) => { + test(`should render and warn if the number of characters typed exceeds the set characterLimit of ${limit} and the enforceCharacterLimit prop is false`, async ({ + mount, + page, + }) => { + await mount(); + + const inputValue = "12345678901"; + const underCharacters = + limit - inputValue.length === 1 ? "character" : "characters"; + const overCharacters = + inputValue.length - limit === 1 ? "character" : "characters"; + + await textboxInput(page).fill(inputValue); + + if (inputValue.length > limit) { + await expect(characterLimit(page)).toHaveText( + `${inputValue.length - limit} ${overCharacters} too many` + ); + await expect(characterLimit(page)).toHaveCSS( + "color", + "rgb(203, 55, 74)" + ); + } else { + await expect(characterLimit(page)).toHaveText( + `${limit - inputValue.length} ${underCharacters} left` + ); + await expect(characterLimit(page)).toHaveCSS( + "color", + "rgba(0, 0, 0, 0.55)" + ); + } + }); + }); + + test("should render with name prop", async ({ mount, page }) => { + await mount(); + + await expect(textboxInput(page)).toHaveAttribute( + "name", + CHARACTERS.STANDARD + ); + }); + + test("should render with disabled prop", async ({ mount, page }) => { + await mount(); + + await expect(textboxInput(page)).toBeDisabled(); + }); + + test("should render with an icon and the disabled prop", async ({ + mount, + page, + }) => { + await mount(); + + const inputIcon = textbox(page).locator(ICON); + + await expect(inputIcon).toBeVisible(); + await expect(inputIcon).toHaveCSS("color", "rgba(0, 0, 0, 0.3)"); + }); + + testData.forEach((placeholder) => { + test(`should render with placeholder prop ${placeholder}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(textboxInput(page)).toHaveAttribute( + "placeholder", + placeholder + ); + }); + }); + + test("should render with autoFocus prop and correct styling when focusRedesignOpt out is false", async ({ + mount, + page, + }) => { + await mount(, { + hooksConfig: { focusRedesignOptOut: false }, + }); + + await expect(textboxInput(page)).toBeFocused(); + await expect(textbox(page)).toHaveCSS( + "box-shadow", + "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px" + ); + await expect(textbox(page)).toHaveCSS( + "outline", + "rgba(0, 0, 0, 0) solid 3px" + ); + }); + + test("should render with autoFocus prop and correct styling when focusRedesignOpt out is true", async ({ + mount, + page, + }) => { + await mount(, { + hooksConfig: { focusRedesignOptOut: true }, + }); + + await expect(textboxInput(page)).toBeFocused(); + await checkGoldenOutline(textbox(page)); + }); + + test("should render with readOnly prop", async ({ mount, page }) => { + await mount(); + + await expect(textboxInput(page)).not.toBeEditable(); + }); + + test("should render with an icon and the readOnly prop", async ({ + mount, + page, + }) => { + await mount(); + + const inputIcon = textbox(page).locator(ICON); + + await expect(inputIcon).toBeVisible(); + await expect(inputIcon).toHaveCSS("color", "rgba(0, 0, 0, 0.3)"); + }); + + ["error", "warning", "info"].forEach((type) => { + test(`should render with a ${type} validation icon on input`, async ({ + mount, + page, + }) => { + await mount( + + ); + + await expect(textbox(page).locator(ICON)).toHaveAttribute( + "data-element", + type + ); + }); + }); + + ["error", "warning", "info"].forEach((type) => { + test(`should render with a ${type} validation icon on label`, async ({ + mount, + page, + }) => { + await mount( + + ); + + const labelParent = getDataElementByValue(page, "label").locator(".."); + const validationIcon = labelParent.locator(`[data-element="${type}"]`); + + await expect(validationIcon).toBeVisible(); + }); + }); + + ([ + [VALIDATION.ERROR, "error", true], + [VALIDATION.WARNING, "warning", true], + [VALIDATION.INFO, "info", true], + ["rgb(102, 132, 148)", "error", false], + ["rgb(102, 132, 148)", "warning", false], + ["rgb(102, 132, 148)", "info", false], + ] as [string, string, boolean][]).forEach(([borderColor, type, bool]) => { + test(`should render with ${borderColor} input border when validation type ${type} is ${bool}`, async ({ + mount, + page, + }) => { + await mount( + + ); + + await expect(textbox(page)).toHaveCSS("border-bottom-color", borderColor); + }); + }); + + test("should render with leftChildren prop", async ({ mount, page }) => { + await mount(); + + await expect(textbox(page).locator(BUTTON)).toBeVisible(); + }); + + testData.forEach((prefix) => { + test(`should render with prefix prop ${prefix}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(textboxPrefix(page)).toHaveText(prefix); + await expect(textboxPrefix(page)).toHaveCSS("font-size", "14px"); + await expect(textboxPrefix(page)).toHaveCSS("font-weight", "900"); + await expect(textboxPrefix(page)).toHaveCSS("margin-left", "12px"); + }); + }); + + [ + ["", "without", "row-reverse"], + ["foo", "with", "row"], + ].forEach(([prefix, prefixStatus, flexDirection]) => { + test(`should render ${prefixStatus} prefix prop when the align prop is also set to 'right'`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(textbox(page)).toHaveCSS("flex-direction", flexDirection); + }); + }); + + test("should render with positionedChildren prop", async ({ + mount, + page, + }) => { + await mount(); + + const textboxParent = textbox(page).locator(".."); + await expect(textboxParent.locator("button")).toBeVisible(); + }); + + ([ + ["flex", 399], + ["flex", 400], + ["block", 401], + ] as [string, TextboxProps["adaptiveLabelBreakpoint"]][]).forEach( + ([displayValue, breakpoint]) => { + test(`should render with ${displayValue} label alignment when the adaptiveLabelBreakpoint prop is ${breakpoint} with a set viewport of 400`, async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 400, height: 300 }); + await mount(); + + const labelParentParent = getDataElementByValue(page, "label") + .locator("..") + .locator(".."); + await expect(labelParentParent).toHaveCSS("display", displayValue); + }); + } + ); + + test("should render with labelId prop", async ({ mount, page }) => { + await mount(); + + const label = getDataElementByValue(page, "label"); + + await expect(label).toHaveAttribute("id", `${CHARACTERS.STANDARD}-label`); + await expect(label).toHaveAttribute("for", CHARACTERS.STANDARD); + }); + + testData.forEach(([fieldHelp]) => { + test(`should render with fieldHelp prop ${fieldHelp}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(fieldHelpPreview(page)).toHaveText(fieldHelp); + }); + }); + + test("should render with formattedValue prop", async ({ mount, page }) => { + await mount(); + + await expect(textboxInput(page)).toHaveAttribute( + "value", + CHARACTERS.STANDARD + ); + }); + + (["add", "filter", "play"] as TextboxProps["inputIcon"][]).forEach((icon) => { + test(`should render with ${icon} input icon`, async ({ mount, page }) => { + await mount(); + + const inputIcon = getDataElementByValue(page, "input-icon-toggle"); + await expect(textbox(page).locator(inputIcon)).toBeVisible(); + }); + }); + + test("should render with iconTabIndex prop", async ({ mount, page }) => { + await mount(); + + const inputIcon = getDataElementByValue(page, "input-icon-toggle"); + + await expect(inputIcon).toHaveAttribute("tabindex", "25"); + }); + + ([ + "top", + "bottom", + "left", + "right", + ] as TextboxProps["tooltipPosition"][]).forEach((position) => { + test(`should render with tooltip positioned to the ${position}`, async ({ + mount, + page, + }) => { + await mount( + + + + ); + + await getDataElementByValue(page, "error").hover(); + + await expect(tooltipPreview(page)).toHaveText(CHARACTERS.STANDARD); + await expect(tooltipPreview(page)).toHaveAttribute( + "data-placement", + position as string + ); + }); + }); + + test("should render with helpAriaLabel prop", async ({ mount, page }) => { + await mount( + + ); + + const help = getDataComponentByValue(page, "help"); + + await expect(help).toHaveAttribute("aria-label", CHARACTERS.STANDARD); + }); + + (["left", "right"] as TextboxProps["align"][]).forEach((align) => { + test(`should render with align prop set to ${align}`, async ({ + mount, + page, + }) => { + await mount(); + + await expect(textboxInput(page)).toHaveCSS("text-align", align as string); + }); + }); + + test("should render with inputRef prop and focus on input via click on ref", async ({ + mount, + page, + }) => { + await mount(); + + await getDataComponentByValue(page, "button").click(); + + await expect(textboxInput(page)).toBeFocused(); + }); + + testData.forEach((value) => { + test(`should render and input ${value} as value`, async ({ + mount, + page, + }) => { + await mount(); + + await textboxInput(page).fill(value); + + await expect(textboxInput(page)).toHaveValue(value); + }); + }); +}); + +test.describe("Event checks", () => { + test("should call onChange callback when a type event is triggered", async ({ + mount, + page, + }) => { + const inputValue = "1"; + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await textboxInput(page).type(inputValue); + + expect(callbackCount).toBe(1); + }); + + test("should call onBlur callback when a blur event is triggered", async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await textboxInput(page).click(); + await textboxInput(page).blur(); + + 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 textboxInput(page).click(); + + 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 textboxInput(page).focus(); + + expect(callbackCount).toBe(1); + }); + + test("should call onMouseDown callback when a mousedown event is triggered", async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await textboxInput(page).dispatchEvent("mousedown"); + + expect(callbackCount).toBe(1); + }); + + test("should call iconOnMouseDown callback when a click event is triggered", async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + inputIcon="add" + /> + ); + + await getDataComponentByValue(page, "icon").click({ button: "left" }); + + expect(callbackCount).toBe(1); + }); + + test("should call iconOnClick callback when a click event is triggered", async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + inputIcon="add" + /> + ); + + await getDataComponentByValue(page, "icon").click(); + + expect(callbackCount).toBe(1); + }); + + [keysToTrigger[0], keysToTrigger[1]].forEach((key) => { + test(`should call iconOnClick callback when ${key} key is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + iconTabIndex={0} + /> + ); + + await getDataElementByValue(page, "input-icon-toggle").press(key); + + expect(callbackCount).toEqual(1); + }); + }); + + [keysToTrigger[0], keysToTrigger[1]].forEach((key) => { + test(`should call onKeyDown callback when ${key} key is triggered`, async ({ + mount, + page, + }) => { + let callbackCount = 0; + await mount( + { + callbackCount += 1; + }} + /> + ); + + await textboxInput(page).press(key); + + expect(callbackCount).toEqual(1); + }); + }); +}); + +test("Component should have the expected border radius styling", async ({ + mount, + page, +}) => { + await mount(); + await expect(textboxInput(page)).toHaveCSS("border-radius", "4px"); +}); + +test.describe("Accessibility tests for Textbox component", () => { + test("should pass accessibility tests for default component with a set value and label", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when autoFocus prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when characterLimit prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when disabled prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + (["left", "right"] as TextboxProps["align"][]).forEach((align) => { + test(`should pass accessibility tests when LabelAlign prop is ${align}`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + }); + + test("should pass accessibility tests when margin prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when prefix prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when readOnly prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when required prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + (["small", "medium", "large"] as TextboxProps["size"][]).forEach((align) => { + test(`should pass accessibility tests when sizes prop is ${align}`, async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + }); + + test("should pass accessibility tests when opted into new validation designs", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when boolean validations are passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when string validations are passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when string validations are passed and displayed on label", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when custom string validations are passed and displayed on tooltip", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when string validations are displayed on tooltip", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when inputWidth and labelWidth props are passed and labelInline is true", async ({ + mount, + page, + }) => { + await mount( + + ); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when maxWidth prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when fieldHelp prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when labelHelp and helpAriaLabel props are passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); + + test("should pass accessibility tests when labelInline prop is passed", async ({ + mount, + page, + }) => { + await mount(); + + await checkAccessibility(page); + }); +}); diff --git a/src/components/toast/toast-test.stories.tsx b/src/components/toast/toast-test.stories.tsx index a64b616e19..ec0b7f473b 100644 --- a/src/components/toast/toast-test.stories.tsx +++ b/src/components/toast/toast-test.stories.tsx @@ -7,7 +7,7 @@ import { TOAST_COLORS } from "./toast.config"; export default { title: "Toast/Test", - includeStories: ["Default", "Visual"], + includeStories: ["Default", "Visual", "AllAlign", "TopAndBottom"], parameters: { info: { disable: true }, chromatic: { @@ -234,6 +234,164 @@ Visual.parameters = { themeProvider: { chromatic: { theme: "sage" } }, }; +export const AllAlign = () => { + const [isOpen, setIsOpen] = useState(true); + const onDismissClick = () => { + setIsOpen(!isOpen); + }; + return ( + + + My text + + + + My text + + + + My text + + + + My text + + + + My text + + + + My text + + + + My text + + + + My text + + + + My text + + + ); +}; + +AllAlign.storyName = "all align"; + +export const TopAndBottom = () => { + const [isOpen, setIsOpen] = useState(true); + const handleOpen = () => { + setIsOpen(!isOpen); + action("open")(!isOpen); + }; + return ( + <> + + Open Toasts + + + My Toast A + + + My Toast B + + > + ); +}; + +TopAndBottom.storyName = "top and bottom"; + export const ToastComponent = ({ children = "Toast", ...props diff --git a/src/components/toast/toast.component.tsx b/src/components/toast/toast.component.tsx index d2a600dc0e..b889a35213 100644 --- a/src/components/toast/toast.component.tsx +++ b/src/components/toast/toast.component.tsx @@ -26,6 +26,7 @@ type ToastVariants = | "notification"; type AlignOptions = "left" | "center" | "right"; +type AlignYOptions = "top" | "center" | "bottom"; interface IconTypes { notification: "alert"; @@ -38,8 +39,10 @@ interface IconTypes { } export interface ToastProps { - /** Sets the alignment of the component. */ + /** Sets the horizontal alignment of the component. */ align?: AlignOptions; + /** Sets the vertical alignment of the component */ + alignY?: AlignYOptions; /** The rendered children of the component. */ children: React.ReactNode; /** Customizes the appearance in the DLS theme */ @@ -77,6 +80,7 @@ export const Toast = React.forwardRef( ( { align, + alignY, children, className, id, @@ -208,6 +212,7 @@ export const Toast = React.forwardRef( > ( diff --git a/src/components/toast/toast.spec.tsx b/src/components/toast/toast.spec.tsx index f555c48de1..f97c256502 100644 --- a/src/components/toast/toast.spec.tsx +++ b/src/components/toast/toast.spec.tsx @@ -571,7 +571,7 @@ describe("TestContentStyle", () => { }); }); -describe("Align", () => { +describe("Align horizontal", () => { let wrapper: ReactWrapper; afterEach(() => { @@ -593,6 +593,53 @@ describe("Align", () => { ); }); +describe("Align vertical", () => { + let wrapper: ReactWrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + it.each(["top", "center", "bottom"] as const)( + "when align prop is %s, Portal is correctly positioned", + (alignYValue) => { + wrapper = mount( + + FooBar + + ); + expect(wrapper.find(StyledPortal).props().alignY).toBe(alignYValue); + } + ); + + it("when isNotice is set and alignY is set to top, should render with the correct style", () => { + wrapper = mount( + + Foo + + ); + assertStyleMatch({ marginTop: "0" }, wrapper.find(ToastStyle)); + }); +}); + +describe("Align vertical and horizontal", () => { + let wrapper: ReactWrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + it("should pass align set to left and alignY set to center to StyledPortal", () => { + wrapper = mount( + + FooBar + + ); + expect(wrapper.find(StyledPortal).props().align).toBe("left"); + expect(wrapper.find(StyledPortal).props().alignY).toBe("center"); + }); +}); + describe("Notification variant", () => { let wrapper: ReactWrapper; diff --git a/src/components/toast/toast.stories.mdx b/src/components/toast/toast.stories.mdx index 1a14ee5376..95e0cdde0d 100644 --- a/src/components/toast/toast.stories.mdx +++ b/src/components/toast/toast.stories.mdx @@ -76,22 +76,40 @@ Toast variant is `success` by default -### Align - Left +### Horizontal Align - Left - + -### Align - Center +### Horizontal Align - Center - + -### Align - Right +### Horizontal Align - Right - + + + +### Vertical Align - Top + + + + + +### Vertical Align - Center + + + + + +### Vertical Align - Bottom + + + ### Info @@ -121,7 +139,8 @@ Toast variant is `success` by default ### Notice When the "notice" variant is set, the Toast component is rendered at the bottom of the screen with alternative styling and animation. -The "isCenter" and "maxWidth" props will be ignored in this variant. +The "isCenter" and "maxWidth" props will be ignored in this variant. +To render it at the top of the screen use the "alignY" prop set to "top". diff --git a/src/components/toast/toast.stories.tsx b/src/components/toast/toast.stories.tsx index 8b96d85702..c3cba5263b 100644 --- a/src/components/toast/toast.stories.tsx +++ b/src/components/toast/toast.stories.tsx @@ -354,6 +354,111 @@ export const AlignedRight = () => { ); }; +export const AlignedYTop = () => { + const [isOpen, setIsOpen] = useState(false); + const onDismissClick = () => { + setIsOpen(!isOpen); + }; + const handleToggle = () => { + if (!isOpen) { + window.scrollTo(0, 0); + } + setIsOpen(!isOpen); + }; + + return ( + + + Toggle - Preview is: {isOpen ? "ON" : "OFF"} + + + My text + + + ); +}; + +export const AlignedYCenter = () => { + const [isOpen, setIsOpen] = useState(false); + const onDismissClick = () => { + setIsOpen(!isOpen); + }; + const handleToggle = () => { + if (!isOpen) { + window.scrollTo(0, 0); + } + setIsOpen(!isOpen); + }; + + return ( + + + Toggle - Preview is: {isOpen ? "ON" : "OFF"} + + + My text + + + ); +}; + +export const AlignedYBottom = () => { + const [isOpen, setIsOpen] = useState(false); + const onDismissClick = () => { + setIsOpen(!isOpen); + }; + const handleToggle = () => { + if (!isOpen) { + window.scrollTo(0, 0); + } + setIsOpen(!isOpen); + }; + + return ( + + + Toggle - Preview is: {isOpen ? "ON" : "OFF"} + + + My text + + + ); +}; + export const CustomMaxWidth = () => { const [isOpen, setIsOpen] = useState(false); const onDismissClick = () => { diff --git a/src/components/toast/toast.style.ts b/src/components/toast/toast.style.ts index 493041a681..68e976cdff 100644 --- a/src/components/toast/toast.style.ts +++ b/src/components/toast/toast.style.ts @@ -10,10 +10,11 @@ import StyledIcon from "../icon/icon.style"; const StyledPortal = styled(Portal)<{ align?: "left" | "center" | "right"; + alignY?: "top" | "center" | "bottom"; isCenter?: boolean; isNotice?: boolean; }>` - ${({ theme, isCenter, isNotice, align }) => css` + ${({ theme, isCenter, isNotice, align, alignY }) => css` position: fixed; top: 0; @@ -27,8 +28,8 @@ const StyledPortal = styled(Portal)<{ ${align === "left" && css` - left: 12%; - transform: translateX(-50%); + left: 0; + transform: translateX(50%); `} ${align === "center" && @@ -39,7 +40,6 @@ const StyledPortal = styled(Portal)<{ ${align === "right" && css` - display: flex; right: 0; transform: translateX(-50%); `} @@ -49,6 +49,26 @@ const StyledPortal = styled(Portal)<{ bottom: 0; top: auto; width: 100%; + `} + + ${alignY === "top" && + css` + top: 0; + bottom: auto; + `} + + ${alignY === "center" && + css` + top: 50%; + transform: translate(${align === "left" ? "50%" : "-50%"}, -50%); + `} + + ${alignY === "bottom" && + css` + bottom: 0; + top: auto; + display: flex; + flex-direction: column-reverse; `} `} `; @@ -61,19 +81,24 @@ const animationName = ".toast"; const alternativeAnimationName = ".toast-alternative"; const ToastStyle = styled(MessageStyle)<{ align?: "left" | "center" | "right"; + alignY?: "top" | "center" | "bottom"; maxWidth?: string; isCenter?: boolean; isNotice?: boolean; isNotification?: boolean; }>` - ${({ maxWidth, isCenter, align, isNotification }) => css` + ${({ maxWidth, isCenter, align, isNotification, alignY, isNotice }) => css` box-shadow: 0 10px 30px 0 rgba(0, 20, 29, 0.1), 0 30px 60px 0 rgba(0, 20, 29, 0.1); line-height: 22px; - margin-top: 30px; + margin-top: ${(alignY === "top" && isNotice) || alignY === "center" + ? "0" + : "30px"}; + margin-bottom: ${alignY === "bottom" && !isNotice ? "30px" : "0"}; max-width: ${!maxWidth ? "300px" : maxWidth}; position: relative; margin-right: ${isCenter || align === "right" ? "auto" : "30px"}; + margin-left: ${isCenter || align === "left" ? "auto" : "30px"}; ${isNotification && css` @@ -100,7 +125,9 @@ const ToastStyle = styled(MessageStyle)<{ &${animationName}-exit${animationName}-exit-active { opacity: 0; - margin-top: -40px; + + ${({ alignY }) => + alignY === "bottom" ? "margin-bottom: -40px" : "margin-top: -40px"}; transition: all 150ms ease-out; } @@ -111,7 +138,7 @@ const ToastStyle = styled(MessageStyle)<{ transform: translateY(-50%); } - ${({ isNotice }) => + ${({ isNotice, alignY }) => isNotice && css` background-color: var(--colorsUtilityMajor400); @@ -129,24 +156,24 @@ const ToastStyle = styled(MessageStyle)<{ } &${alternativeAnimationName}-appear, &${alternativeAnimationName}-enter { - bottom: -40px; + ${alignY === "top" ? "top: -40px" : "bottom: -40px"}; opacity: 0; } &${alternativeAnimationName}-exit { - bottom: 0; + ${alignY === "top" ? "top: 0" : "bottom: 0"}; opacity: 1; } &${alternativeAnimationName}-appear${alternativeAnimationName}-appear-active, &${alternativeAnimationName}-enter${alternativeAnimationName}-enter-active { - bottom: 0; + ${alignY === "top" ? "top: 0" : "bottom: 0"}; opacity: 1; transition: all 400ms ease; } &${alternativeAnimationName}-exit${alternativeAnimationName}-exit-active { - bottom: -40px; + ${alignY === "top" ? "top: -40px" : "bottom: -40px"}; opacity: 0; transition: all 200ms ease; } diff --git a/src/style/assets/carbon-icons-webfont.woff b/src/style/assets/carbon-icons-webfont.woff index 972be7184c..8d1d40d862 100644 Binary files a/src/style/assets/carbon-icons-webfont.woff and b/src/style/assets/carbon-icons-webfont.woff differ