Skip to content

Commit

Permalink
feat(accordion): clickable title (#1033)
Browse files Browse the repository at this point in the history
  • Loading branch information
TylerR909 authored May 29, 2024
1 parent 90d4e65 commit df9a8d6
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 34 deletions.
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}>
<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

0 comments on commit df9a8d6

Please sign in to comment.