diff --git a/src/components/confirm/confirm.component.tsx b/src/components/confirm/confirm.component.tsx index 14f0685694..708ffe3dca 100644 --- a/src/components/confirm/confirm.component.tsx +++ b/src/components/confirm/confirm.component.tsx @@ -155,7 +155,7 @@ export const Confirm = ({ })} > {isLoadingConfirm ? ( - + ) : ( confirmLabel || l.confirm.yes() )} diff --git a/src/components/confirm/confirm.pw.tsx b/src/components/confirm/confirm.pw.tsx index 7f6f47a91b..7fb254d751 100644 --- a/src/components/confirm/confirm.pw.tsx +++ b/src/components/confirm/confirm.pw.tsx @@ -8,6 +8,7 @@ import { } from "./components.test-pw"; import { getDataElementByValue, + getDataRoleByValue, icon, } from "../../../playwright/components/index"; import { @@ -240,7 +241,7 @@ test.describe("should render Confirm component", () => { const button = getDataElementByValue(page, "confirm"); await expect(button).toHaveAttribute("disabled", ""); - const loader = page.getByLabel("Loading"); + const loader = getDataRoleByValue(page, "confirm-loader"); await expect(loader).toBeAttached(); }); diff --git a/src/components/confirm/confirm.test.tsx b/src/components/confirm/confirm.test.tsx index a8b2fef16f..e3491cd473 100644 --- a/src/components/confirm/confirm.test.tsx +++ b/src/components/confirm/confirm.test.tsx @@ -129,7 +129,7 @@ test("should render disabled confirm button with Loader if isLoadingConfirm is s ); expect(screen.queryByRole("button", { name: "Yes" })).not.toBeInTheDocument(); - expect(screen.getByRole("progressbar", { name: "Loading" })).toBeVisible(); + expect(screen.getByTestId("confirm-loader")).toBeVisible(); expect(screen.getByTestId("confirm-button")).toBeDisabled(); }); diff --git a/src/components/loader/loader-test.stories.tsx b/src/components/loader/loader-test.stories.tsx index 756398e9e3..38d1db1801 100644 --- a/src/components/loader/loader-test.stories.tsx +++ b/src/components/loader/loader-test.stories.tsx @@ -31,7 +31,7 @@ export default { }, }, variant: { - options: ["default", "colorful"], + options: ["default", "gradient"], control: { type: "select", }, diff --git a/src/components/loader/loader.component.tsx b/src/components/loader/loader.component.tsx index f0bfc68f32..4306082c3c 100644 --- a/src/components/loader/loader.component.tsx +++ b/src/components/loader/loader.component.tsx @@ -10,6 +10,8 @@ import StyledLoader from "./loader.style"; import StyledLoaderSquare, { StyledLoaderSquareProps, } from "./loader-square.style"; +import Typography from "../typography"; +import Logger from "../../__internal__/utils/logger"; export interface LoaderProps extends Omit, @@ -17,18 +19,33 @@ export interface LoaderProps TagProps { /** Toggle between the default variant and gradient variant */ variant?: string; - /** Specify a custom accessible name for the Loader component */ + /** [Deprecated] Specify a custom accessible name for the Loader component */ "aria-label"?: string; + /** + * Specify a custom accessible label for the Loader. + * This label will be present if the user has `reduce-motion` enabled and will also be available to assistive technologies. + */ + loaderLabel?: string; } +let deprecateAriaLabelWarnTriggered = false; + export const Loader = ({ variant = "default", "aria-label": ariaLabel, size = "medium", isInsideButton, isActive = true, + loaderLabel, ...rest }: LoaderProps) => { + if (!deprecateAriaLabelWarnTriggered && ariaLabel) { + deprecateAriaLabelWarnTriggered = true; + Logger.deprecate( + "The aria-label prop in Loader is deprecated and will soon be removed, please use the `loaderLabel` prop instead to provide an accessible label.", + ); + } + const l = useLocale(); const reduceMotion = !useMediaQuery( @@ -45,13 +62,11 @@ export const Loader = ({ // FE-6368 has been raised for the below, changed hex values for design tokens (when added) return ( {reduceMotion ? ( - l.loader.loading() + loaderLabel || ariaLabel || l.loader.loading() ) : ( <> {["#13A038", "#0092DB", "#8F49FE"].map((color) => ( @@ -66,6 +81,9 @@ export const Loader = ({ {...loaderSquareProps} /> ))} + + {loaderLabel || ariaLabel || l.loader.loading()} + )} diff --git a/src/components/loader/loader.mdx b/src/components/loader/loader.mdx index 7b0c45834c..3a80b43529 100644 --- a/src/components/loader/loader.mdx +++ b/src/components/loader/loader.mdx @@ -67,13 +67,17 @@ This is an example of the large Loader component. The larger size is only used w This example shows a `Loader` nested inside of a `Button` component. To ensure that the correct styling is applied to the `Loader` component when it is nested inside of the `Button` component, please remember to pass the `isInsideButton` prop to the `Loader` component. -This example also demonstrates how to ensure that the `Loader` component is accessible when used inside of the `Button` component. -It is important to wrap the `Button` component in a `span` (use when elements are inline) or `div` (use when elements are in a block) tag and pass the `aria-live` attribute with the value of `polite`. This will ensure that the screen reader will reliabily announce the updates of the button to the user. + + +### Conditional Rendering + +In most cases, Loaders are conditionally rendered on the page and are then replaced by content. +It is important to wrap the relevant content in a `span` (when elements are inline) or a `div` (when elements are in a block) and pass the `aria-live` attribute with the value of `polite`. Please note that ARIA live regions provide a means for conveying dynamic updates to users who rely on assistive technologies like screen readers. -These updates in this case include changes to the button's content. By incorporating live regions permanently into the DOM, we can ensure that users are consistently informed of relevant changes as they occur. +By incorporating live regions permanently into the DOM, we can ensure that users are consistently informed of relevant changes as they occur. - + ## Props diff --git a/src/components/loader/loader.pw.tsx b/src/components/loader/loader.pw.tsx index 75800b7846..d32e0f9b79 100644 --- a/src/components/loader/loader.pw.tsx +++ b/src/components/loader/loader.pw.tsx @@ -110,15 +110,6 @@ test.describe("check props for Loader component test", () => { expect(await colorVal(2)).toBe(color); }); - test("should render Loader with aria-label prop", async ({ mount, page }) => { - await mount(); - - await expect(loader(page, 0).locator("..")).toHaveAttribute( - "aria-label", - "playwright-aria", - ); - }); - test("should render Loader with isActive prop set to false", async ({ mount, page, diff --git a/src/components/loader/loader.stories.tsx b/src/components/loader/loader.stories.tsx index c42b84ded7..28c99d5116 100644 --- a/src/components/loader/loader.stories.tsx +++ b/src/components/loader/loader.stories.tsx @@ -52,18 +52,35 @@ export const InsideButton: Story = () => { mimicLoading(); }; const buttonContent = isLoading ? : "Click me"; - const ariaContent = isLoading ? "Loading" : "Click me"; + return ( - - - + ); }; InsideButton.storyName = "Inside Button"; + +export const ConditionalRendering = () => { + const [isLoading, setIsLoading] = useState(false); + const mimicLoading = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 5000); + }; + const handleButtonClick = () => { + mimicLoading(); + }; + + return ( +
+ + + {isLoading ? : "Content to Load"} +
+ ); +}; +ConditionalRendering.storyName = "Conditional Rendering"; diff --git a/src/components/loader/loader.test.tsx b/src/components/loader/loader.test.tsx index 531054efea..5ccf708000 100644 --- a/src/components/loader/loader.test.tsx +++ b/src/components/loader/loader.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils"; import Loader from "."; import useMediaQuery from "../../hooks/useMediaQuery"; +import Logger from "../../__internal__/utils/logger"; jest.mock("../../hooks/useMediaQuery", () => ({ __esModule: true, @@ -10,14 +11,47 @@ jest.mock("../../hooks/useMediaQuery", () => ({ })); testStyledSystemMargin( - (props) => , - () => screen.getByRole("progressbar"), + (props) => , + () => screen.getByTestId("loader"), ); +test("throws a deprecation warning if the 'aria-label' prop is set", () => { + const loggerSpy = jest + .spyOn(Logger, "deprecate") + .mockImplementation(() => {}); + + render(); + + expect(loggerSpy).toHaveBeenCalledWith( + "The aria-label prop in Loader is deprecated and will soon be removed, please use the `loaderLabel` prop instead to provide an accessible label.", + ); + expect(loggerSpy).toHaveBeenCalledTimes(1); + + loggerSpy.mockRestore(); +}); + test("when the user disallows animations or their preference cannot be determined, alternative loading text is rendered", () => { render(); - expect(screen.getByText("Loading")).toBeInTheDocument(); + expect(screen.getByText("Loading")).toBeVisible(); +}); + +test("when the user disallows animations or their preference cannot be determined, the provided `aria-label` is rendered", () => { + const loggerSpy = jest + .spyOn(Logger, "deprecate") + .mockImplementation(() => {}); + + render(); + + expect(screen.getByText("Still loading")).toBeVisible(); + + loggerSpy.mockRestore(); +}); + +test("when the user disallows animations or their preference cannot be determined, the provided `loaderLabel` is rendered", () => { + render(); + + expect(screen.getByText("Still loading")).toBeVisible(); }); describe("when the user allows animations", () => { @@ -36,17 +70,27 @@ describe("when the user allows animations", () => { expect(squares).toHaveLength(3); }); - test("root element has accessible name", () => { - render(); + test("root element has default accessible name", () => { + render(); + + expect(screen.getByTestId("loader")).toHaveTextContent("Loading"); + }); + + test("should set visually hidden label to provided `aria-label`", () => { + const loggerSpy = jest + .spyOn(Logger, "deprecate") + .mockImplementation(() => {}); + + render(); + + expect(screen.getByTestId("loader")).toHaveTextContent("Still loading"); - expect(screen.getByRole("progressbar")).toHaveAccessibleName("Loading"); + loggerSpy.mockRestore(); }); - test("when custom `aria-label` is passed, set accessible name to its value", () => { - render(); + test("should set visually hidden label to provided `loaderLabel`", () => { + render(); - expect(screen.getByRole("progressbar")).toHaveAccessibleName( - "Still loading", - ); + expect(screen.getByTestId("loader")).toHaveTextContent("Still loading"); }); }); diff --git a/src/components/select/__internal__/select-list/select-list.component.tsx b/src/components/select/__internal__/select-list/select-list.component.tsx index 587343f31b..bf5ca599fb 100644 --- a/src/components/select/__internal__/select-list/select-list.component.tsx +++ b/src/components/select/__internal__/select-list/select-list.component.tsx @@ -671,7 +671,7 @@ const SelectList = React.forwardRef( const loader = isLoading ? ( - + ) : undefined; diff --git a/src/components/select/__internal__/select-list/select-list.test.tsx b/src/components/select/__internal__/select-list/select-list.test.tsx index 3fbf528474..1636b85280 100644 --- a/src/components/select/__internal__/select-list/select-list.test.tsx +++ b/src/components/select/__internal__/select-list/select-list.test.tsx @@ -81,7 +81,7 @@ describe("rendered content", () => { , ); - expect(screen.getByRole("progressbar", { name: "Loading" })).toBeVisible(); + expect(screen.getByTestId("select-list-loader")).toBeVisible(); }); it("renders options in a table layout when multiColumn prop is true", () => { diff --git a/src/components/switch/__internal__/switch-slider-panel.test.tsx b/src/components/switch/__internal__/switch-slider-panel.test.tsx index 0a59faad0e..55cb1ff80b 100644 --- a/src/components/switch/__internal__/switch-slider-panel.test.tsx +++ b/src/components/switch/__internal__/switch-slider-panel.test.tsx @@ -17,7 +17,7 @@ beforeEach(() => { test("when `loading` is true, the correct Loader styles are applied", () => { render( {}} loading />); - const loaderElement = screen.getByRole("progressbar"); + const loaderElement = screen.getByTestId("switch-slider-loader"); expect(loaderElement).toBeVisible(); expect(loaderElement).toHaveStyle({ diff --git a/src/components/switch/__internal__/switch-slider.component.tsx b/src/components/switch/__internal__/switch-slider.component.tsx index a0797e3a6b..d54d02fd96 100644 --- a/src/components/switch/__internal__/switch-slider.component.tsx +++ b/src/components/switch/__internal__/switch-slider.component.tsx @@ -66,7 +66,11 @@ const SwitchSlider = ({ const sliderContent = ( - {loading ? : panelContent} + {loading ? ( + + ) : ( + panelContent + )} ); diff --git a/src/components/switch/__internal__/switch-slider.test.tsx b/src/components/switch/__internal__/switch-slider.test.tsx index 62567dacd2..8095dafb6f 100644 --- a/src/components/switch/__internal__/switch-slider.test.tsx +++ b/src/components/switch/__internal__/switch-slider.test.tsx @@ -37,7 +37,7 @@ test("when `checked` is true, only one panel renders", () => { test("when `loading` is true, Loader component renders in the first panel", () => { render(); - const loader = screen.getByRole("progressbar"); + const loader = screen.getByTestId("switch-slider-loader"); expect(loader).toBeVisible(); }); diff --git a/src/components/switch/switch.pw.tsx b/src/components/switch/switch.pw.tsx index 3a69ac376c..41c36b769d 100644 --- a/src/components/switch/switch.pw.tsx +++ b/src/components/switch/switch.pw.tsx @@ -22,6 +22,7 @@ import { fieldHelpPreview, tooltipPreview, getDataElementByValue, + getDataRoleByValue, } from "../../../playwright/components/index"; import { assertCssValueIsApproximately, @@ -94,10 +95,9 @@ test.describe("Prop tests for Switch component", () => { if (boolVal) { await expect(switchInput(page)).toBeDisabled(); - await expect(page.getByRole("progressbar")).toHaveAttribute( - "data-component", - "loader", - ); + await expect( + getDataRoleByValue(page, "switch-slider-loader"), + ).toBeVisible(); } else { await expect(switchInput(page)).not.toBeDisabled(); }