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

add new accordion #2773

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

exports[`Gamut Exported Keys 1`] = `
Array [
"Accordion",
"AccordionArea",
"AccordionButton",
"AccordionAreaDeprecated",
"AccordionButtonDeprecated",
"AccordionDeprecated",
"Alert",
"Anchor",
"AnchorBase",
Expand Down
219 changes: 157 additions & 62 deletions packages/gamut/src/Accordion/index.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,187 @@
import { useState } from 'react';
import * as React from 'react';
import { ArrowChevronDownIcon } from '@codecademy/gamut-icons';
import { theme } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import React, {
ReactElement,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';

import { AccordionArea } from '../AccordionArea';
import {
AccordionButton,
AccordionButtonSize,
AccordionButtonTheme,
} from '../AccordionButton';
import { Anchor, Box, FlexBox, Rotation, Text } from '..';

export type ChildrenOrExpandedRender =
| React.ReactNode
| ((expanded: boolean) => React.ReactNode);
type SetStateWithCallbackType<T> = (
state: SetStateAction<T>,
callback?: ((state: T) => void) | undefined
) => void;

export type AccordionProps = {
children: ChildrenOrExpandedRender;
// This hook is identical to the normal `useState`, but you can have the setter take a
// callback function that will be executed with the updated state each time the setter is called.
export const useStateWithCallback = <T extends {}>(
initialState: T
): [T, SetStateWithCallbackType<T>] => {
const [state, setState] = useState<T>(initialState);
const callbackRef = useRef<(state: T) => void>();

// Saves the callback before calling setState as normal.
const setStateWithCallback = useCallback(
(state: SetStateAction<T>, callback?: (state: T) => void) => {
callbackRef.current = callback;
setState(state);
},
[]
);

// As soon as the state gets changed and processed,
// this will call the callback with the updated state.
useEffect(() => {
if (callbackRef.current) {
callbackRef.current(state);
callbackRef.current = undefined;
}
}, [state]);

return [state, setStateWithCallback];
};

export const useToggle = (
initialState: boolean,
onToggle?: (state: boolean) => void
): [boolean, () => void] => {
const [state, setStateWithCallback] = useStateWithCallback<boolean>(
initialState
);

const toggle = useCallback(
() => setStateWithCallback((prevState) => !prevState, onToggle),
[setStateWithCallback, onToggle]
);

return [state, toggle];
};

const StyledAnchor = styled(Anchor)`
&:hover,
&:focus {
color: ${theme.colors.text};
}
`;

const ExpandInCollapseOut: React.FC<React.PropsWithChildren<unknown>> = ({
children,
}) => {
return (
<motion.div
initial="collapsed"
exit="collapsed"
animate="expanded"
style={{ overflow: 'hidden' }}
variants={{
expanded: { height: 'auto' },
collapsed: { height: 0 },
}}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
{children}
</motion.div>
);
};

export interface AccordionProps {
/**
* CSS class name added to the root area container.
* Aria label for the expand/collapse link
*/
className?: string;
ariaLabel: string;

/**
* Whether the accordion should start off with expanded state.
* React element to render within the expand/collapse link
*/
initiallyExpanded?: boolean;
renderHeader: ReactElement;

/**
* Called when the top button is clicked.
*
* @param expanding - New expanded state the accordion will transition to.
* React element to render when accordion is expanded
*/
onClick?: (expanding: boolean) => void;
renderExpanded: ReactElement;

/**
* Visual size of the top button.
* Optional custom text to display instead of the chevron arrow icon
*/
size?: AccordionButtonSize;
customText?: { expanded: string; collapsed: string };

/**
* Visual theme of the top button.
* Whether the accordion should start off with the expanded state
*/
theme?: AccordionButtonTheme;
initiallyExpanded?: boolean;

/**
* Contents to place within the top button.
* Called when the expand/collapse link is clicked
*
* @param isExpanded - New expanded state the accordion will transition to
*/
top: ChildrenOrExpandedRender;
};

/**
* @deprecated
* This component is in the old visual identity and will be updated soon.
*
* Check the [Gamut Board](https://www.notion.so/codecademy/Gamut-Status-Timeline-dd3c135d3848464ea6eb1b48e68fbb1d) for component status
*/
onExpandedOrCollapsed?: (isExpanded: boolean) => void;
}

export const Accordion: React.FC<AccordionProps> = ({
children,
className,
customText,
renderHeader,
initiallyExpanded,
onClick,
size,
theme,
top,
ariaLabel,
renderExpanded,
onExpandedOrCollapsed,
}) => {
const [expanded, setExpanded] = useState(!!initiallyExpanded);
const expandRenderer = (renderer: ChildrenOrExpandedRender) =>
renderer instanceof Function ? renderer(expanded) : renderer;
const [isExpanded, toggleIsExpanded] = useToggle(!!initiallyExpanded);

return (
<AccordionArea
className={className}
expanded={expanded}
top={
<AccordionButton
expanded={expanded}
onClick={() => {
setExpanded(!expanded);
onClick?.(!expanded);
}}
size={size}
theme={theme}
<FlexBox flexDirection="column" width="100%">
<StyledAnchor
aria-label={
customText
? isExpanded
? `${ariaLabel} ${customText.expanded}`
: `${ariaLabel} ${customText.collapsed}`
: ariaLabel
}
aria-expanded={isExpanded}
onClick={() => {
toggleIsExpanded();
onExpandedOrCollapsed?.(isExpanded);
}}
variant="interface"
width="100%"
py={{ _: 16, sm: 32 }}
px={{ _: 0, sm: 64, lg: 0 }}
m={4}
>
<FlexBox
flexDirection={{ _: 'column', sm: 'row' }}
justifyContent="space-between"
center
>
{expandRenderer(top)}
</AccordionButton>
}
>
{expandRenderer(children)}
</AccordionArea>
{renderHeader}
{customText ? (
<Text minWidth={100} aria-hidden pt={{ _: 24, sm: 0 }}>
{isExpanded ? customText.expanded : customText.collapsed}
</Text>
) : (
<Rotation rotated={isExpanded}>
<ArrowChevronDownIcon color="text-disabled" />
</Rotation>
)}
</FlexBox>
</StyledAnchor>

<AnimatePresence>
{isExpanded && (
<ExpandInCollapseOut>
<Box role="region" aria-label={`${ariaLabel} expanded`}>
{renderExpanded}
</Box>
</ExpandInCollapseOut>
)}
</AnimatePresence>
</FlexBox>
);
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { setupRtl } from '@codecademy/gamut-tests';
import { act } from 'react-dom/test-utils';

import { AccordionArea } from '..';
import { AccordionAreaDeprecated } from '..';

const defaultProps = {
children: <div data-testid="contents" />,
top: 'Click me!',
};
const renderView = setupRtl(AccordionArea, defaultProps);
const renderView = setupRtl(AccordionAreaDeprecated, defaultProps);

jest.useFakeTimers();

describe('AccordionArea', () => {
describe('AccordionAreaDeprecated', () => {
it('starts collapsed when expanded is not true', () => {
const { view } = renderView({ expanded: false });

Expand All @@ -27,15 +27,17 @@ describe('AccordionArea', () => {
it('expands when props change to expand', () => {
const { view } = renderView({ expanded: false });

view.rerender(<AccordionArea {...defaultProps} expanded />);
view.rerender(<AccordionAreaDeprecated {...defaultProps} expanded />);

view.getByTestId('contents');
});

it('contracts after a delay when set to not expanded after being expanded', async () => {
const { view } = renderView({ expanded: true });

view.rerender(<AccordionArea {...defaultProps} expanded={false} />);
view.rerender(
<AccordionAreaDeprecated {...defaultProps} expanded={false} />
);

await act(async () => {
jest.runAllTimers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from 'react';
import * as React from 'react';
import { useIsomorphicLayoutEffect } from 'react-use';

export type AccordionAreaProps = {
export type AccordionAreaDeprecatedProps = {
children: React.ReactNode;

className?: string;
Expand Down Expand Up @@ -34,7 +34,7 @@ const variants = {
* Check the [Gamut Board](https://www.notion.so/codecademy/Gamut-Status-Timeline-dd3c135d3848464ea6eb1b48e68fbb1d) for component status
*/

export const AccordionArea: React.FC<AccordionAreaProps> = ({
export const AccordionAreaDeprecated: React.FC<AccordionAreaDeprecatedProps> = ({
children,
className,
expanded,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { setupRtl } from '@codecademy/gamut-tests';
import { fireEvent } from '@testing-library/dom';
import { cleanup } from '@testing-library/react';

import { AccordionButton, AccordionButtonTheme } from '..';
import { AccordionButtonDeprecated, AccordionButtonTheme } from '..';

const onClick = jest.fn();

const renderView = setupRtl(AccordionButton, {
const renderView = setupRtl(AccordionButtonDeprecated, {
onClick,
children: 'Hi there!',
size: 'normal',
theme: 'yellow',
});

describe('AccordionButton', () => {
describe('AccordionButtonDeprecated', () => {
afterEach(() => {
cleanup();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type AccordionButtonSize = 'normal' | 'large';

export type AccordionButtonTheme = 'blue' | 'plain' | 'yellow';

export type AccordionButtonProps = ButtonDeprecatedBaseProps & {
export type AccordionButtonDeprecatedProps = ButtonDeprecatedBaseProps & {
/**
* Whether the button should display as open or closed.
*/
Expand Down Expand Up @@ -59,7 +59,7 @@ const buttonThemes = {
* Check the [Gamut Board](https://www.notion.so/codecademy/Gamut-Status-Timeline-dd3c135d3848464ea6eb1b48e68fbb1d) for component status
*/

export const AccordionButton: React.FC<AccordionButtonProps> = ({
export const AccordionButtonDeprecated: React.FC<AccordionButtonDeprecatedProps> = ({
children,
className,
expanded,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { setupRtl } from '@codecademy/gamut-tests';
import { fireEvent } from '@testing-library/dom';
import { act } from 'react-dom/test-utils';

import { Accordion } from '..';
import { AccordionDeprecated } from '..';

const renderView = setupRtl(Accordion, {
const renderView = setupRtl(AccordionDeprecated, {
children: <div data-testid="contents" />,
top: 'Click me!',
});

jest.useFakeTimers();

describe('Accordion', () => {
describe('AccordionDeprecated', () => {
it('starts collapsed when initiallyExpanded is not true', () => {
const { view } = renderView({ initiallyExpanded: false });

Expand Down