Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/client/packages/design-system/ads/src/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
startIcon,
UNSAFE_height,
UNSAFE_width,
// Extract aria-label to ensure it's properly passed for accessibility
"aria-label": ariaLabel,
...rest
} = props;

Expand All @@ -49,6 +51,10 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
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 (
<StyledButton
as={renderAs || "button"}
Expand All @@ -57,6 +63,8 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{...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}
Expand Down
15 changes: 15 additions & 0 deletions app/client/packages/design-system/ads/src/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function Select(props: SelectProps) {
isDisabled = false,
isLoading = false,
isMultiSelect,
isRequired = false,
isValid,
maxTagCount = isMultiSelect
? props.value?.length > 1
Expand All @@ -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<HTMLInputElement>(null);
Expand Down Expand Up @@ -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 (
<RCSelect
{...rest}
aria-busy={isLoading}
aria-describedby={ariaDescribedby}
aria-invalid={ariaInvalid}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-required={isRequired || undefined}
className={clsx(SelectClassName, className)}
clearIcon={<Icon name="close-circle-line" size="md" />}
data-is-valid={isValid}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
285 changes: 285 additions & 0 deletions app/client/src/components/common/Collapsible.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider theme={lightTheme}>
<Collapsible label="Test Collapsible" {...props}>
<div data-testid="collapsible-content">Collapsible Content</div>
</Collapsible>
</ThemeProvider>,
);
};

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);
}
Comment on lines +47 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent test skip on missing element.

If .closest(".icon-text") returns null, the assertion inside the if block is silently skipped, and the test passes without actually clicking anything. This can mask regressions.

Consider throwing or asserting the element exists:

      const label = screen.getByText("Test Collapsible").closest(".icon-text");
-      if (label) {
-        fireEvent.click(label);
-      }
+      expect(label).not.toBeNull();
+      fireEvent.click(label!);

Same pattern applies to lines 64-67, 88-91, and 275-278.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const label = screen.getByText("Test Collapsible").closest(".icon-text");
if (label) {
fireEvent.click(label);
}
const label = screen.getByText("Test Collapsible").closest(".icon-text");
expect(label).not.toBeNull();
fireEvent.click(label!);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/client/src/components/common/Collapsible.test.tsx` around lines 47 - 50,
The test is silently skipping clicks when .closest(".icon-text") returns null;
update each occurrence (e.g., the use around getByText("Test Collapsible") in
Collapsible.test.tsx and the similar blocks at the other noted spots) to assert
the element exists before using it — for example assert/expect that the
closest(".icon-text") is not null (or throw if null) and then perform
fireEvent.click; do this for the blocks at the other locations (lines analogous
to 64-67, 88-91, and 275-278) so the test fails loudly if the element is missing
instead of silently passing.


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();
});
Comment on lines +107 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how Icon component renders the name prop
ast-grep --pattern $'function Icon($$$) {
  $$$
}'
rg -n "name.*arrow-right" --type=tsx -A3 -B3

Repository: appsmithorg/appsmith

Length of output: 8759


🏁 Script executed:

cat -n app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx

Repository: appsmithorg/appsmith

Length of output: 50376


🏁 Script executed:

# Check the Collapsible component to see how it uses Icon
cat -n app/client/src/components/common/Collapsible.tsx | sed -n '130,150p'

Repository: appsmithorg/appsmith

Length of output: 772


🏁 Script executed:

# Search for other test usage of [name="..."] selectors to see if this pattern is used elsewhere
rg '\[name=' app/client/src/components/common --type=tsx -B2 -A2

Repository: appsmithorg/appsmith

Length of output: 91


🏁 Script executed:

rg "arrow-right-s-line" app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx

Repository: appsmithorg/appsmith

Length of output: 108


🏁 Script executed:

# Find and read the IconProvider implementation
find app/client/packages/design-system/ads/src/Icon -name "*.tsx" -o -name "*.ts" | head -20

Repository: appsmithorg/appsmith

Length of output: 680


🏁 Script executed:

# Search for other test files using [name=" selector pattern
rg '\[name=' app/client --type=tsx | head -20

Repository: appsmithorg/appsmith

Length of output: 91


🏁 Script executed:

cat -n app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx | head -100

Repository: appsmithorg/appsmith

Length of output: 4080


🏁 Script executed:

# Search for how tests query icon elements with [name= pattern
rg 'querySelector.*name=' app/client/src --type=tsx -B3 -A3 | head -50

Repository: appsmithorg/appsmith

Length of output: 91


🏁 Script executed:

grep -r '\[name=' app/client/src --include="*.tsx" --include="*.ts" | grep querySelector | head -30

Repository: appsmithorg/appsmith

Length of output: 729


🏁 Script executed:

# Look at the Icon test file to see how it tests icon rendering
cat -n app/client/packages/design-system/ads/src/Icon/Icon.test.tsx

Repository: appsmithorg/appsmith

Length of output: 1632


🏁 Script executed:

# Get the IconProvider component implementation
sed -n '1200,1300p' app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx

Repository: appsmithorg/appsmith

Length of output: 3279


🏁 Script executed:

# Search for the IconProvider export/component definition
rg "export.*IconProvider\|const IconProvider\|function IconProvider" app/client/packages/design-system/ads/src/Icon/

Repository: appsmithorg/appsmith

Length of output: 46


🏁 Script executed:

# Check if SVG or icon elements have a name attribute in the render
rg -A5 "return.*name\|\.name\s*=" app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx | head -40

Repository: appsmithorg/appsmith

Length of output: 46


🏁 Script executed:

# Find where IconProvider is defined and how it renders
rg "IconProvider" app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx | grep -E "export|function|const" | head -20

Repository: appsmithorg/appsmith

Length of output: 101


🏁 Script executed:

# Look at the end of Icon.provider.tsx file to see the export/implementation
tail -100 app/client/packages/design-system/ads/src/Icon/Icon.provider.tsx

Repository: appsmithorg/appsmith

Length of output: 2609


🏁 Script executed:

# Check how remixicon-react SVG icons render - look at loadables to see if name attr is added
cat -n app/client/packages/design-system/ads/src/Icon/loadables.tsx

Repository: appsmithorg/appsmith

Length of output: 2209


🏁 Script executed:

# Look at the Accordion test to see if it's also failing or if there's a pattern
cat -n app/client/src/pages/AdminSettings/FormGroup/Accordion.test.tsx | sed -n '1,100p'

Repository: appsmithorg/appsmith

Length of output: 3441


🏁 Script executed:

# Check the complete Icon.tsx to see if name attribute is added anywhere
cat -n app/client/packages/design-system/ads/src/Icon/Icon.tsx

Repository: appsmithorg/appsmith

Length of output: 1219


🏁 Script executed:

# Check if there's any test setup or utility that adds name attributes
rg "name=" app/client/src/components/common/Collapsible.test.tsx -B5 -A5

Repository: appsmithorg/appsmith

Length of output: 675


🏁 Script executed:

# Look at the full Collapsible.test.tsx file to understand the test
cat -n app/client/src/components/common/Collapsible.test.tsx

Repository: appsmithorg/appsmith

Length of output: 11250


Selector will not match rendered icon element.

The selector [name="arrow-right-s-line"] expects a DOM element with a name attribute, but the Icon component does not render the name prop as an HTML attribute. The name prop is destructured and only passed to IconProvider as iconName, which is not exposed to the DOM. Use .collapsible-icon instead (as done in other tests at lines 36 and 103).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/client/src/components/common/Collapsible.test.tsx` around lines 107 -
112, The test "should show arrow-right icon when collapsed" uses a selector
'[name="arrow-right-s-line"]' which won't match because the Icon's name prop is
not rendered into the DOM; update the assertion in the test that calls
renderCollapsible to query the visible icon using the existing DOM class
selector '.collapsible-icon' (consistent with other tests around lines 36 and
103) so the test checks container.querySelector('.collapsible-icon') and asserts
it is in the document.

});

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(
<ThemeProvider theme={lightTheme}>
<Collapsible label="Test" expand={false}>
<div data-testid="content">Content</div>
</Collapsible>
</ThemeProvider>,
);

expect(screen.queryByTestId("content")).not.toBeInTheDocument();

rerender(
<ThemeProvider theme={lightTheme}>
<Collapsible label="Test" expand={true}>
<div data-testid="content">Content</div>
</Collapsible>
</ThemeProvider>,
);

await waitFor(() => {
expect(screen.getByTestId("content")).toBeInTheDocument();
});
});
});

describe("CustomLabelComponent", () => {
it("should render custom label component when provided", () => {
const CustomLabel = ({ datasource }: { datasource?: unknown }) => (
<span data-testid="custom-label">Custom Label</span>
);

renderCollapsible({ CustomLabelComponent: CustomLabel });

expect(screen.getByTestId("custom-label")).toBeInTheDocument();
});
});

describe("DisabledCollapsible", () => {
it("should render with disabled styling", () => {
const { container } = render(
<ThemeProvider theme={lightTheme}>
<DisabledCollapsible label="Disabled Section" />
</ThemeProvider>,
);

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(
<ThemeProvider theme={lightTheme}>
<DisabledCollapsible
label="Disabled Section"
tooltipLabel="This section is disabled"
/>
</ThemeProvider>,
);

expect(screen.getByText("Disabled Section")).toBeInTheDocument();
});
});

describe("CollapsibleGroup", () => {
it("should render children within a group", () => {
render(
<ThemeProvider theme={lightTheme}>
<CollapsibleGroup>
<div data-testid="group-child">Group Child</div>
</CollapsibleGroup>
</ThemeProvider>,
);

expect(screen.getByTestId("group-child")).toBeInTheDocument();
});

it("should apply height and maxHeight props", () => {
const { container } = render(
<ThemeProvider theme={lightTheme}>
<CollapsibleGroup height="500px" maxHeight="600px">
<div>Content</div>
</CollapsibleGroup>
</ThemeProvider>,
);

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(
<ThemeProvider theme={lightTheme}>
<Collapsible label="Test" expand={true}>
<div>Content</div>
</Collapsible>
</ThemeProvider>,
);

// Check that icon indicates expanded state
let icon = container.querySelector(".collapsible-icon");
expect(icon).toBeInTheDocument();

// Re-render with collapsed state
rerender(
<ThemeProvider theme={lightTheme}>
<Collapsible label="Test" expand={false}>
<div>Content</div>
</Collapsible>
</ThemeProvider>,
);

// 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);
});
});
});
});
Loading