diff --git a/app/client/packages/design-system/ads/src/Button/Button.tsx b/app/client/packages/design-system/ads/src/Button/Button.tsx index 4fe30a4265b6..32d7edb1059c 100644 --- a/app/client/packages/design-system/ads/src/Button/Button.tsx +++ b/app/client/packages/design-system/ads/src/Button/Button.tsx @@ -40,6 +40,8 @@ const Button = forwardRef( startIcon, UNSAFE_height, UNSAFE_width, + // Extract aria-label to ensure it's properly passed for accessibility + "aria-label": ariaLabel, ...rest } = props; @@ -49,6 +51,10 @@ const Button = forwardRef( const buttonRef = useDOMRef(ref); const { focusProps, isFocusVisible } = useFocusRing(); + // Determine aria-label for accessibility + // Icon-only buttons should have an aria-label for screen readers + const computedAriaLabel = ariaLabel; + return ( ( {...focusProps} UNSAFE_height={UNSAFE_height} UNSAFE_width={UNSAFE_width} + aria-busy={isLoading} + aria-label={computedAriaLabel} className={clsx(ButtonClassName, className)} data-disabled={props.isDisabled || false} data-loading={isLoading} diff --git a/app/client/packages/design-system/ads/src/Select/Select.tsx b/app/client/packages/design-system/ads/src/Select/Select.tsx index 49780c5b2f4a..5c5da78f9535 100644 --- a/app/client/packages/design-system/ads/src/Select/Select.tsx +++ b/app/client/packages/design-system/ads/src/Select/Select.tsx @@ -30,6 +30,7 @@ function Select(props: SelectProps) { isDisabled = false, isLoading = false, isMultiSelect, + isRequired = false, isValid, maxTagCount = isMultiSelect ? props.value?.length > 1 @@ -41,6 +42,10 @@ function Select(props: SelectProps) { showSearch = false, size = "md", virtual = false, + // Extract aria props for accessibility + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + "aria-describedby": ariaDescribedby, ...rest } = props; const searchRef = useRef(null); @@ -76,9 +81,19 @@ function Select(props: SelectProps) { setSearchValue(""); }; + // Determine aria-invalid based on isValid prop + // When isValid is explicitly false, the field has a validation error + const ariaInvalid = isValid === false ? "true" : undefined; + return ( } data-is-valid={isValid} diff --git a/app/client/packages/design-system/ads/src/Select/Select.types.ts b/app/client/packages/design-system/ads/src/Select/Select.types.ts index 3905d97162b2..90a1988798fc 100644 --- a/app/client/packages/design-system/ads/src/Select/Select.types.ts +++ b/app/client/packages/design-system/ads/src/Select/Select.types.ts @@ -11,6 +11,8 @@ export type SelectProps = RCSelectProps & { isDisabled?: boolean; isValid?: boolean; isLoading?: boolean; + /** Whether the select field is required (for accessibility) */ + isRequired?: boolean; dropdownMatchSelectWidth?: boolean | number; }; diff --git a/app/client/src/components/common/Collapsible.test.tsx b/app/client/src/components/common/Collapsible.test.tsx new file mode 100644 index 000000000000..8a64ac8f60c6 --- /dev/null +++ b/app/client/src/components/common/Collapsible.test.tsx @@ -0,0 +1,285 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { ThemeProvider } from "styled-components"; +import { lightTheme } from "selectors/themeSelectors"; +import { Collapsible, CollapsibleGroup, DisabledCollapsible } from "./Collapsible"; + +const renderCollapsible = (props = {}) => { + return render( + + +
Collapsible Content
+
+
, + ); +}; + +describe("Collapsible Accessibility Tests", () => { + describe("ARIA Attributes", () => { + it("should render the collapsible component with a label", () => { + renderCollapsible(); + + expect(screen.getByText("Test Collapsible")).toBeInTheDocument(); + }); + + it("should render children when expanded by default", () => { + renderCollapsible({ expand: true }); + + expect(screen.getByTestId("collapsible-content")).toBeInTheDocument(); + }); + + it("should have an expandable icon that indicates state", () => { + const { container } = renderCollapsible({ expand: true }); + + const icon = container.querySelector(".collapsible-icon"); + expect(icon).toBeInTheDocument(); + }); + + it("should toggle expansion state when clicking the label", async () => { + renderCollapsible({ expand: false }); + + // Initially collapsed + expect(screen.queryByTestId("collapsible-content")).not.toBeInTheDocument(); + + // Click to expand + const label = screen.getByText("Test Collapsible").closest(".icon-text"); + if (label) { + fireEvent.click(label); + } + + await waitFor(() => { + expect(screen.getByTestId("collapsible-content")).toBeInTheDocument(); + }); + }); + + it("should collapse when clicking the label on expanded state", async () => { + renderCollapsible({ expand: true }); + + // Initially expanded + expect(screen.getByTestId("collapsible-content")).toBeInTheDocument(); + + // Click to collapse + const label = screen.getByText("Test Collapsible").closest(".icon-text"); + if (label) { + fireEvent.click(label); + } + + await waitFor(() => { + expect(screen.queryByTestId("collapsible-content")).not.toBeInTheDocument(); + }); + }); + }); + + describe("Keyboard Accessibility", () => { + it("should have a clickable label element", () => { + const { container } = renderCollapsible(); + + const label = container.querySelector(".icon-text"); + expect(label).toBeInTheDocument(); + expect(label).toHaveStyle({ cursor: "pointer" }); + }); + + it("should be accessible via click interaction", async () => { + const handleCustomCollapse = jest.fn(); + renderCollapsible({ handleCustomCollapse }); + + const label = screen.getByText("Test Collapsible").closest(".icon-text"); + if (label) { + fireEvent.click(label); + } + + await waitFor(() => { + expect(handleCustomCollapse).toHaveBeenCalled(); + }); + }); + }); + + describe("Icon State Indication", () => { + it("should show down-arrow icon when expanded", () => { + const { container } = renderCollapsible({ expand: true }); + + const icon = container.querySelector(".collapsible-icon"); + expect(icon).toBeInTheDocument(); + }); + + it("should show arrow-right icon when collapsed", () => { + const { container } = renderCollapsible({ expand: false }); + + const icon = container.querySelector('[name="arrow-right-s-line"]'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe("State Management", () => { + it("should respect initial expand prop", () => { + renderCollapsible({ expand: true }); + + expect(screen.getByTestId("collapsible-content")).toBeInTheDocument(); + }); + + it("should respect initial collapsed state", () => { + renderCollapsible({ expand: false }); + + expect(screen.queryByTestId("collapsible-content")).not.toBeInTheDocument(); + }); + + it("should update state when expand prop changes", async () => { + const { rerender } = render( + + +
Content
+
+
, + ); + + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + + rerender( + + +
Content
+
+
, + ); + + await waitFor(() => { + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + }); + }); + + describe("CustomLabelComponent", () => { + it("should render custom label component when provided", () => { + const CustomLabel = ({ datasource }: { datasource?: unknown }) => ( + Custom Label + ); + + renderCollapsible({ CustomLabelComponent: CustomLabel }); + + expect(screen.getByTestId("custom-label")).toBeInTheDocument(); + }); + }); + + describe("DisabledCollapsible", () => { + it("should render with disabled styling", () => { + const { container } = render( + + + , + ); + + expect(screen.getByText("Disabled Section")).toBeInTheDocument(); + + // Check for disabled styling + const wrapper = container.querySelector('[class*="CollapsibleWrapper"]'); + expect(wrapper).toBeInTheDocument(); + }); + + it("should show tooltip when provided", () => { + render( + + + , + ); + + expect(screen.getByText("Disabled Section")).toBeInTheDocument(); + }); + }); + + describe("CollapsibleGroup", () => { + it("should render children within a group", () => { + render( + + +
Group Child
+
+
, + ); + + expect(screen.getByTestId("group-child")).toBeInTheDocument(); + }); + + it("should apply height and maxHeight props", () => { + const { container } = render( + + +
Content
+
+
, + ); + + const wrapper = container.firstChild; + expect(wrapper).toBeInTheDocument(); + }); + }); + + describe("Accessibility Recommendations", () => { + /** + * Note: The Collapsible component currently lacks some accessibility attributes + * that would improve screen reader support: + * + * - aria-expanded: Should be "true" when expanded, "false" when collapsed + * - aria-controls: Should reference the ID of the content panel + * - role="button": The clickable label should have this role + * - tabIndex="0": To make it keyboard focusable + * - Keyboard support: Enter and Space keys should toggle the collapse state + * + * These tests document the expected behavior for accessibility compliance. + */ + + it("should have a label that is clickable", () => { + const { container } = renderCollapsible(); + + const label = container.querySelector(".icon-text"); + expect(label).toBeInTheDocument(); + }); + + it("should provide visual indication of expanded/collapsed state", () => { + const { container, rerender } = render( + + +
Content
+
+
, + ); + + // Check that icon indicates expanded state + let icon = container.querySelector(".collapsible-icon"); + expect(icon).toBeInTheDocument(); + + // Re-render with collapsed state + rerender( + + +
Content
+
+
, + ); + + // Icon should still be present but may have different attributes + icon = container.querySelector('[name="arrow-right-s-line"]'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe("Handle Custom Collapse", () => { + it("should call handleCustomCollapse when toggle occurs", async () => { + const handleCustomCollapse = jest.fn(); + renderCollapsible({ handleCustomCollapse, expand: true }); + + const label = screen.getByText("Test Collapsible").closest(".icon-text"); + if (label) { + fireEvent.click(label); + } + + await waitFor(() => { + expect(handleCustomCollapse).toHaveBeenCalledWith(false); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/client/src/components/editorComponents/Button.test.tsx b/app/client/src/components/editorComponents/Button.test.tsx new file mode 100644 index 000000000000..1904274f34e4 --- /dev/null +++ b/app/client/src/components/editorComponents/Button.test.tsx @@ -0,0 +1,412 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { ThemeProvider } from "styled-components"; +import { lightTheme } from "selectors/themeSelectors"; +import Button, { type ButtonProps } from "./Button"; + +const renderButton = (props: Partial = {}) => { + return render( + + + , + ); + + expect(screen.getByRole("button", { name: "Child Content" })).toBeInTheDocument(); + }); + + it("should be accessible when only icon is provided", () => { + renderButton({ icon: "plus", text: undefined }); + + const button = screen.getByRole("button"); + // Icon-only buttons should have an accessible name via aria-label + expect(button).toBeInTheDocument(); + }); + }); + + describe("Form Integration", () => { + it("should work as submit button in forms", () => { + const onSubmit = jest.fn((e) => e.preventDefault()); + + render( + +
+