Skip to content

Commit

Permalink
feat(Accordion): add expandOnTileClick for mobile-first interaction
Browse files Browse the repository at this point in the history
Co-Authored-By: Jozef Képesi <[email protected]>
  • Loading branch information
2 people authored and mvidalgarcia committed Dec 17, 2024
1 parent b78d7c5 commit 9019919
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 31 deletions.
55 changes: 55 additions & 0 deletions packages/orbit-components/src/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,58 @@ export const LoadingAccordion: Story = {
</Accordion>
),
};

export const MobileFirstInteraction: Story = {
render: function Render() {
const [expandedSection, setExpandedSection] = React.useState("");

return (
<Stack>
<Text>Traditional button-based expansion:</Text>
<Accordion
expandedSection={expandedSection}
onExpand={id => setExpandedSection(String(id))}
>
<AccordionSection
id="traditional"
header={
<Stack spacing="300">
<Text type="primary">Click the button to expand</Text>
<Text size="small">Uses traditional button-based expansion</Text>
</Stack>
}
>
<Text type="primary">This section uses the traditional button-based expansion.</Text>
</AccordionSection>
</Accordion>

<Text spaceAfter="large">Mobile-first tile click expansion:</Text>
<Accordion
expandedSection={expandedSection}
onExpand={id => setExpandedSection(String(id))}
>
<AccordionSection
id="mobile"
expandOnTileClick
header={
<Stack spacing="300">
<Text type="primary">Click anywhere on the header to expand</Text>
<Text size="small">Uses mobile-first tile click interaction</Text>
</Stack>
}
>
<Text type="primary">
This section uses the mobile-first expandOnTileClick interaction.
</Text>
<Text type="primary">
The entire header area is clickable for better mobile usability.
</Text>
<Text type="primary">
Keyboard users can still use Enter or Space to expand/collapse.
</Text>
</AccordionSection>
</Accordion>
</Stack>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface Props extends Common.Globals {
readonly children: React.ReactNode;
readonly expanded: boolean;
readonly expandable: boolean;
readonly expandOnTileClick?: boolean;
readonly onExpand?: Common.Callback;
readonly actions?: React.ReactNode;
}
Expand All @@ -16,25 +17,73 @@ const AccordionSectionHeader = ({
children,
actions,
expanded,
expandOnTileClick,
onExpand,
expandable,
dataTest,
}: Props) => (
<div
className={cx("p-600 flex items-center", expanded ? "min-h-[19px]" : "min-h-form-box-normal")}
data-test={dataTest && `${dataTest}Header`}
>
<div className="flex grow items-center">{children}</div>
{!expanded && expandable && (
<div className="ms-600 flex">
{actions || (
<Button onClick={onExpand} type="secondary">
Open
</Button>
)}
</div>
)}
</div>
);
}: Props) => {
const isInteractive = expandOnTileClick && !expanded && expandable;

const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
onExpand?.();
},
[onExpand],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onExpand?.();
}
},
[onExpand],
);

const handleButtonClick = React.useCallback(
(e: React.SyntheticEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
onExpand?.();
},
[onExpand],
);

const content = (
<>
<div className="flex grow items-center">{children}</div>
{!expanded && expandable && (
<div className="ms-600 flex">
{actions || (
<Button onClick={handleButtonClick} type="secondary">
Open
</Button>
)}
</div>
)}
</>
);

return (
<div
className={cx(
"p-600 flex w-full items-center",
expanded ? "min-h-[19px]" : "min-h-form-box-normal",
isInteractive && "hover:bg-cloud-light cursor-pointer border-0 bg-transparent text-left",
)}
data-test={dataTest && `${dataTest}Header`}
aria-expanded={expanded}
{...(isInteractive && {
role: "button",
onClick: handleClick,
onKeyDown: handleKeyDown,
tabIndex: 0,
})}
>
{content}
</div>
);
};

export default AccordionSectionHeader;
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const AccordionSection = ({
actions,
dataTest,
expandable = true,
expandOnTileClick = false,
}: Props) => {
const { expanded, onExpand, loading } = useAccordion();

Expand All @@ -40,6 +41,7 @@ const AccordionSection = ({
expanded={Boolean(isExpanded)}
onExpand={onExpand}
expandable={expandable}
expandOnTileClick={expandOnTileClick}
dataTest={dataTest}
>
{header}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface Props extends Common.Globals {
readonly actions?: React.ReactNode;
readonly expanded?: boolean;
readonly expandable?: boolean;
readonly expandOnTileClick?: boolean; // Mobile-first interaction
readonly onExpand?: Common.Callback;
readonly header?: React.ReactNode;
readonly footer?: React.ReactNode;
Expand Down
23 changes: 12 additions & 11 deletions packages/orbit-components/src/Accordion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,18 @@ After adding import into your project you can use it simply like:

## Props

| Name | Type | Required | Default | Description |
| ---------- | ------------------ | -------- | ------- | ----------- |
| id | `string \| number` | | | |
| children | `React.ReactNode` | | | |
| actions | `React.ReactNode` | | | |
| expanded | `boolean` | | | |
| expandable | `boolean` | | | |
| onExpand | `Common.Callback` | | | |
| header | `React.ReactNode` | | | |
| footer | `React.ReactNode` | | | |
| dataTest | `string` | | | |
| Name | Type | Required | Default | Description |
| ----------------- | ------------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------- |
| id | `string \| number` | | | |
| children | `React.ReactNode` | | | |
| actions | `React.ReactNode` | | | |
| expanded | `boolean` | | | |
| expandable | `boolean` | | | |
| expandOnTileClick | `boolean` | | `false` | If true, enables mobile-first interaction where the entire header area becomes clickable for expansion. |
| onExpand | `Common.Callback` | | | |
| header | `React.ReactNode` | | | |
| footer | `React.ReactNode` | | | |
| dataTest | `string` | | | |

### Callback

Expand Down
83 changes: 80 additions & 3 deletions packages/orbit-components/src/Accordion/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from "react";

import { render, screen } from "../../test-utils";
import { render, screen, fireEvent, waitFor } from "../../test-utils";
import Accordion, { AccordionSection } from "..";

describe(`Accordion`, () => {
describe("Accordion", () => {
const expandedSection = "0X1";
const dataTest = "Accordion";
const id = "accordionId";
Expand Down Expand Up @@ -35,7 +35,7 @@ describe(`Accordion`, () => {
expect(screen.getByTestId(`${sectionDataTest}Loading`)).toBeInTheDocument();
});

describe(`AccordionSection`, () => {
describe("AccordionSection", () => {
it("should render passed components", () => {
render(
<AccordionSection
Expand All @@ -53,5 +53,82 @@ describe(`Accordion`, () => {
expect(screen.getByTestId(`${sectionDataTest}Content`)).toBeInTheDocument();
expect(screen.getByTestId(`${sectionDataTest}Footer`)).toBeInTheDocument();
});

describe("expandOnTileClick functionality", () => {
const sectionId = "test";

beforeEach(() => {
onExpand.mockClear();
});

it("should expand when header is clicked and expandOnTileClick is true", async () => {
const clickTestId = `${sectionDataTest}-click`;
const expandHandler = onExpand;
render(
<Accordion onExpand={expandHandler} expandedSection={undefined}>
<AccordionSection
id={sectionId}
header="Clickable Header"
expandOnTileClick
dataTest={clickTestId}
/>
</Accordion>,
);

const header = screen.getByTestId(`${clickTestId}Header`);
expect(header).toHaveClass("cursor-pointer");

await waitFor(() => {
fireEvent.click(header);
});

expect(expandHandler).toHaveBeenCalledWith(sectionId);
});

it("should not expand when header is clicked and expandOnTileClick is false", async () => {
const noClickTestId = `${sectionDataTest}-no-click`;
const expandHandler = onExpand;
render(
<Accordion onExpand={expandHandler} expandedSection={undefined}>
<AccordionSection
id={sectionId}
header="Non-Clickable Header"
dataTest={noClickTestId}
/>
</Accordion>,
);

const header = screen.getByTestId(`${noClickTestId}Header`);

await waitFor(() => {
fireEvent.click(header);
});

expect(expandHandler).not.toHaveBeenCalled();
});

it("should maintain button click functionality with expandOnTileClick", async () => {
const buttonTestId = `${sectionDataTest}-button`;
const expandHandler = onExpand;
render(
<Accordion onExpand={expandHandler} expandedSection={undefined}>
<AccordionSection
id={sectionId}
header="Header with Both"
expandOnTileClick
dataTest={buttonTestId}
/>
</Accordion>,
);

const button = screen.getByRole("button", { name: "Open" });

await waitFor(() => {
fireEvent.click(button);
});

expect(expandHandler).toHaveBeenCalledWith(sectionId);
});
});
});
});

0 comments on commit 9019919

Please sign in to comment.