Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(accordion): clickable title #1033

Merged
merged 4 commits into from
May 29, 2024
Merged
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
15 changes: 15 additions & 0 deletions src/components/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ export function AccordionVariations() {
Our modern approach to homebuilding makes the process easier and more personal than ever before.
</div>
</Accordion>
<Accordion title="Clickable Title" titleOnClick={() => {}}>
<div css={Css.sm.$}>
Our modern approach to homebuilding makes the process easier and more personal than ever before.
</div>
</Accordion>
<Accordion title="Compact" compact>
<div css={Css.sm.$}>
Our modern approach to homebuilding makes the process easier and more personal than ever before.
</div>
</Accordion>
<Accordion title="Clickable Title + Compact" titleOnClick={() => {}} compact>
<div css={Css.sm.$}>
Our modern approach to homebuilding makes the process easier and more personal than ever before.
</div>
</Accordion>
</>
);
}
Expand Down
36 changes: 35 additions & 1 deletion src/components/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from "src/utils/rtl";
import { click, render } from "src/utils/rtl";
import { Accordion } from "./Accordion";

describe(Accordion, () => {
Expand Down Expand Up @@ -72,4 +72,38 @@ describe(Accordion, () => {
// And the content is not displayed
expect(r.query.accordion_content).not.toBeInTheDocument();
});

it("calls the titleOnClick function when the title is clicked", async () => {
// Given an accordion component with titleOnClick set
const titleOnClick = jest.fn();
// When rendered
const r = await render(
<Accordion title="Test title" titleOnClick={titleOnClick}>
Test description
</Accordion>,
);

// Then the titleOnClick function is called when the title is clicked
click(r.accordion_title);
expect(titleOnClick).toHaveBeenCalled();
});

it("alters expando behavior when titleOnClick is provided", async () => {
// When rendered with a titleOnClick set
const r = await render(
<Accordion title="Test title" titleOnClick={() => {}}>
Test description
</Accordion>,
);

// when the title is clicked
click(r.accordion_title);
// then the content is not displayed
expect(r.query.accordion_content).not.toBeInTheDocument();

// when bar is clicked
click(r.accordion_toggle);
// then the content is displayed
expect(r.accordion_content).toHaveTextContent("Test description");
});
});
94 changes: 61 additions & 33 deletions src/components/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useId, useResizeObserver } from "@react-aria/utils";
import { Dispatch, ReactNode, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { Dispatch, ReactNode, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusRing } from "react-aria";
import { Icon } from "src/components/Icon";
import { Css, Only, Padding, Palette, Xss } from "src/Css";
Expand All @@ -23,6 +23,8 @@ export interface AccordionProps<X = AccordionXss> {
*/
index?: number;
setExpandedIndex?: Dispatch<SetStateAction<number | undefined>>;
/** Turns the title into a button. If provided, disables expand/collapse on title text */
titleOnClick?: VoidFunction;
/** Used by Accordion list. Sets default padding to 0 for nested accordions */
omitPadding?: boolean;
/** Styles overrides for padding */
Expand All @@ -43,10 +45,11 @@ export function Accordion<X extends Only<AccordionXss, X>>(props: AccordionProps
bottomBorder = false,
index,
setExpandedIndex,
titleOnClick,
omitPadding = false,
xss,
} = props;
const testIds = useTestIds(props, "accordion");
const tid = useTestIds(props, "accordion");
const id = useId();
const [expanded, setExpanded] = useState(defaultExpanded && !disabled);
const { isFocusVisible, focusProps } = useFocusRing();
Expand Down Expand Up @@ -76,51 +79,62 @@ export function Accordion<X extends Only<AccordionXss, X>>(props: AccordionProps
}, [expanded, setContentHeight]);
useResizeObserver({ ref: contentRef, onResize });

const toggle = useCallback(() => {
setExpanded((prev) => !prev);
if (setExpandedIndex) setExpandedIndex(index);
}, [index, setExpandedIndex]);

const touchableStyle = useMemo(
() => ({
...Css.df.jcsb.gapPx(12).aic.p2.baseMd.outline("none").onHover.bgGray100.if(!!titleOnClick).baseSb.$,
...(compact && Css.smMd.pl2.prPx(10).py1.bgGray100.mbPx(4).br8.onHover.bgGray200.$),
...(compact && !!titleOnClick && Css.br0.$),
...(disabled && Css.gray500.$),
...(isFocusVisible && Css.boxShadow(`inset 0 0 0 2px ${Palette.Blue700}`).$),
...xss,
}),
[compact, disabled, isFocusVisible, titleOnClick, xss],
);

return (
<div
{...testIds.container}
{...tid.container}
css={{
...Css.bGray300.if(topBorder).bt.if(bottomBorder).bb.$,
...(size ? Css.wPx(accordionSizes[size]).$ : {}),
}}
>
<button
{...testIds.title}
{...focusProps}
aria-controls={id}
aria-expanded={expanded}
disabled={disabled}
css={{
...Css.df.jcsb.gapPx(12).aic.w100.p2.baseMd.outline("none").onHover.bgGray100.$,
...(compact && Css.smMd.pl2.prPx(10).py1.bgGray100.mbPx(4).br8.onHover.bgGray200.$),
...(disabled && Css.gray500.$),
...(isFocusVisible && Css.boxShadow(`inset 0 0 0 2px ${Palette.Blue700}`).$),
...xss,
}}
onClick={() => {
setExpanded(!expanded);
if (setExpandedIndex) setExpandedIndex(index);
}}
>
<span css={Css.fg1.tl.$}>{title}</span>
<span
css={{
...Css.fs0.$,
transition: "transform 250ms linear",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
{titleOnClick ? (
<div {...focusProps} aria-controls={id} aria-expanded={expanded} css={Css.df.$}>
<button {...tid.title} disabled={disabled} css={{ ...touchableStyle, ...Css.fg0.$ }} onClick={titleOnClick}>
{title}
</button>
<button {...tid.toggle} disabled={disabled} css={{ ...touchableStyle, ...Css.fg1.jcfe.$ }} onClick={toggle}>
Copy link
Contributor

Choose a reason for hiding this comment

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

I think personally I would switch the fg0/1 and have the chevon CTA be smaller, but 🤷 could really see that going either way/defer to you/design.

<RotatingChevronIcon expanded={expanded} />
</button>
</div>
) : (
<button
{...tid.title}
{...focusProps}
aria-controls={id}
aria-expanded={expanded}
disabled={disabled}
css={{ ...Css.w100.$, ...touchableStyle }}
onClick={toggle}
>
<Icon icon="chevronDown" />
</span>
</button>
<span css={Css.fg1.tl.$}>{title}</span>
<RotatingChevronIcon expanded={expanded} />
</button>
)}
<div
{...testIds.details}
{...tid.details}
id={id}
aria-hidden={!expanded}
css={Css.overflowHidden.h(contentHeight).add("transition", "height 250ms ease-in-out").$}
>
{expanded && (
<div css={Css.px2.pb2.pt1.if(omitPadding).p0.$} ref={contentRef} {...testIds.content}>
<div css={Css.px2.pb2.pt1.if(omitPadding).p0.$} ref={contentRef} {...tid.content}>
{children}
</div>
)}
Expand All @@ -129,6 +143,20 @@ export function Accordion<X extends Only<AccordionXss, X>>(props: AccordionProps
);
}

function RotatingChevronIcon(props: { expanded: boolean }) {
return (
<span
css={{
...Css.fs0.$,
transition: "transform 250ms linear",
transform: props.expanded ? "rotate(180deg)" : "rotate(0deg)",
}}
>
<Icon icon="chevronDown" />
</span>
);
}

export type AccordionSize = "xs" | "sm" | "md" | "lg";

const accordionSizes: Record<AccordionSize, number> = {
Expand Down
Loading