diff --git a/.changeset/honest-apes-rush.md b/.changeset/honest-apes-rush.md new file mode 100644 index 000000000..61845343d --- /dev/null +++ b/.changeset/honest-apes-rush.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-accordion": minor +--- + +Add animations to Accordion diff --git a/__docs__/wonder-blocks-accordion/accordion-section.argtypes.tsx b/__docs__/wonder-blocks-accordion/accordion-section.argtypes.tsx index 337f19b02..a8a39f0cb 100644 --- a/__docs__/wonder-blocks-accordion/accordion-section.argtypes.tsx +++ b/__docs__/wonder-blocks-accordion/accordion-section.argtypes.tsx @@ -36,10 +36,7 @@ export default { left-to-right language (and on the right of a right-to-left language), and "end" means it’s on the right of a left-to-right language (and on the left of a right-to-left language). - Defaults to "end". - If this prop is specified both here in the \`AccordionSection\` - and within the \`Accordion\` component, the Accordion’s - caretPosition value is prioritized.`, + Defaults to "end".`, defaultValue: "end", table: { defaultValue: {summary: "end"}, @@ -78,6 +75,16 @@ export default { type: {summary: "boolean"}, }, }, + animated: { + control: {type: "boolean"}, + description: `Whether to include animation on the header. This should + be false if the user has \`prefers-reduced-motion\` opted in. + Defaults to false.`, + table: { + defaultValue: {summary: "false"}, + type: {summary: "boolean"}, + }, + }, onToggle: { control: {type: null}, description: "Called when the header is clicked.", diff --git a/__docs__/wonder-blocks-accordion/accordion-section.stories.tsx b/__docs__/wonder-blocks-accordion/accordion-section.stories.tsx index 78d314ca1..582e947bb 100644 --- a/__docs__/wonder-blocks-accordion/accordion-section.stories.tsx +++ b/__docs__/wonder-blocks-accordion/accordion-section.stories.tsx @@ -16,6 +16,49 @@ import packageConfig from "../../packages/wonder-blocks-icon/package.json"; import AccordionSectionArgtypes from "./accordion-section.argtypes"; +/** + * An AccordionSection displays a section of content that can be shown or + * hidden by clicking its header. This is generally used within the Accordion + * component, but it can also be used on its own if you need only one + * collapsible section. + * + * ### Usage + * + * ```jsx + * import { + * Accordion, + * AccordionSection + * } from "@khanacademy/wonder-blocks-accordion"; + * + * // Within an Accordion + * + * + * This is the information present in the first section + * + * + * This is the information present in the second section + * + * + * This is the information present in the third section + * + * + * + * // On its own, controlled + * const [expanded, setExpanded] = React.useState(false); + * + * This is the information present in the standalone section + * + * + * // On its own, uncontrolled + * + * This is the information present in the standalone section + * + * ``` + */ export default { title: "Accordion / AccordionSection", component: AccordionSection, @@ -371,6 +414,36 @@ export const CornerKinds: StoryComponentType = { }, }; +/** + * An AccordionSection can be animated using the `animated` prop. + * This animation includes the caret, the expansion/collapse, and the + * border radius. + * + * If the user has `prefers-reduced-motion` opted in, this animation should + * be disabled. This can be done by passing `animated={false}` to + * the AccordionSection. + * + * If `animated` is specified both here in the AccordionSection + * and within a parent Accordion component, the Accordion's + * `animated` value is prioritized. + */ +export const WithAnimation: StoryComponentType = { + render: () => { + return ( + + Something + + ); + }, +}; + +WithAnimation.parameters = { + chromatic: { + // Disabling because we cannot visually test this in chromatic. + disableSnapshot: true, + }, +}; + /** * An AccordionSection can have custom styles passed in. In this example, * the AccordionSection has a gray background and a border, as well as @@ -449,9 +522,15 @@ export const WithTag: StoryComponentType = { }, }; +const mobile = "@media (max-width: 1023px)"; + const styles = StyleSheet.create({ sideBySide: { flexDirection: "row", + + [mobile]: { + flexDirection: "column", + }, }, fullWidth: { width: "100%", diff --git a/__docs__/wonder-blocks-accordion/accordion.argtypes.tsx b/__docs__/wonder-blocks-accordion/accordion.argtypes.tsx index a231b7588..70c503973 100644 --- a/__docs__/wonder-blocks-accordion/accordion.argtypes.tsx +++ b/__docs__/wonder-blocks-accordion/accordion.argtypes.tsx @@ -74,6 +74,18 @@ export default { required: false, }, }, + animated: { + control: {type: "boolean"}, + description: `Whether to include animation on the header. This should + be false if the user has \`prefers-reduced-motion\` opted in. + Defaults to false.`, + defaultValue: false, + table: { + defaultValue: {summary: false}, + type: {summary: "boolean"}, + }, + type: {name: "boolean", required: false}, + }, style: { control: {type: "object"}, description: "Custom styles for the overall accordion container.", diff --git a/__docs__/wonder-blocks-accordion/accordion.stories.tsx b/__docs__/wonder-blocks-accordion/accordion.stories.tsx index 5194fee1d..4c1d0f82c 100644 --- a/__docs__/wonder-blocks-accordion/accordion.stories.tsx +++ b/__docs__/wonder-blocks-accordion/accordion.stories.tsx @@ -6,6 +6,7 @@ import { Accordion, AccordionSection, } from "@khanacademy/wonder-blocks-accordion"; +import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {tokens} from "@khanacademy/wonder-blocks-theming"; @@ -16,6 +17,35 @@ import packageConfig from "../../packages/wonder-blocks-icon/package.json"; import AccordionArgtypes from "./accordion.argtypes"; +/** + * An accordion displays a vertically stacked list of sections, each of which + * contains content that can be shown or hidden by clicking its header. + * + * The Wonder Blocks Accordion component is a styled wrapper for a list of + * AccordionSection components. It also wraps the AccordionSection + * components in list items. + * + * ### Usage + * + * ```jsx + * import { + * Accordion, + * AccordionSection + * } from "@khanacademy/wonder-blocks-accordion"; + * + * + * + * This is the information present in the first section + * + * + * This is the information present in the second section + * + * + * This is the information present in the third section + * + * + * ``` + */ export default { title: "Accordion / Accordion", component: Accordion, @@ -64,16 +94,41 @@ export const Default: StoryComponentType = { */ export const AllowMultipleExpanded: StoryComponentType = { render: () => ( - - - Allow multiple expanded + + + Allow multiple expanded (default) {exampleSections} - - Allow only one expanded - - {exampleSections} - + + + Allow only one expanded + + {exampleSections} + + + + Allow only one expanded + + {exampleSections} + + + + Allow only one expanded + + {exampleSections} + + ), @@ -221,6 +276,141 @@ export const WithInitialExpandedIndex: StoryComponentType = { }, }; +/** + * An Accordion can be animated using the `animated` prop. This + * animation includes the caret, the expansion/collapse, and the last + * section's border radius. In this example, animated accordions with + * different corner kinds are shown to demonstrate the border radius transition, + * as well as accordions with `allowMultipleExpanded` set to `false`, and + * an accordion with sections of different heights. + * + * If the user has `prefers-reduced-motion` opted in, this animation should + * be disabled. This can be done by passing `animated={false}` to + * the Accordion. + * + * If `animated` is specified both here in the Accordion + * and within a child AccordionSection component, the Accordion's + * `animated` value is prioritized. + */ +export const WithAnimation: StoryComponentType = { + render: () => { + return ( + + + + cornerKind: square + + {exampleSections} + + + + cornerKind: rounded + + {exampleSections} + + + + cornerKind: rounded-per-section + + {exampleSections} + + + + + + + cornerKind: square, allowMultipleExpanded: false + + + {exampleSections} + + + + + cornerKind: rounded, allowMultipleExpanded: false + + + {exampleSections} + + + + + cornerKind: rounded-per-section, + allowMultipleExpanded: false + + + {exampleSections} + + + + + + With unevenly sided sections, allowMultipleExpanded: + false + + + + + This is the information present in the first + section + + + + + This is the information present in the second + section + + + + + This is the information present in the third + section + + + + + + ); + }, +}; + +WithAnimation.parameters = { + chromatic: { + // Disabling because we cannot visually test this in chromatic. + disableSnapshot: true, + }, +}; + /** * An Accordion with custom styles. The custom styles in this example * include a pink border and extra padding. @@ -282,9 +472,67 @@ SingleSection.parameters = { }, }; +/** + * This is an example of an Accordion with many sections, as well as + * a lot of content within each section. + */ +export const LongSections: StoryComponentType = { + name: "Long sections (performance check)", + render: function Render() { + const [shown, setShown] = React.useState(false); + + return ( + + + {shown && ( + + {Array(20).fill( + + + Wonder Blocks logo + + Wonder Blocks logo + + , + )} + + )} + + ); + }, +}; + +LongSections.parameters = { + chromatic: { + // Disabling because we cannot visually test this in chromatic. + disableSnapshot: true, + }, +}; + +const mobile = "@media (max-width: 1023px)"; + const styles = StyleSheet.create({ sideBySide: { flexDirection: "row", + + [mobile]: { + flexDirection: "column", + }, }, fullWidth: { width: "100%", @@ -295,4 +543,8 @@ const styles = StyleSheet.create({ space: { margin: tokens.spacing.xSmall_8, }, + button: { + width: "fit-content", + marginBottom: tokens.spacing.medium_16, + }, }); diff --git a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section-header.test.tsx b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section-header.test.tsx index 06c454bf4..406986340 100644 --- a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section-header.test.tsx +++ b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section-header.test.tsx @@ -12,6 +12,7 @@ describe("AccordionSectionHeader", () => { caretPosition="end" cornerKind="square" expanded={false} + animated={false} onClick={() => {}} sectionContentUniqueId="section-content-unique-id" isFirstSection={false} @@ -34,6 +35,7 @@ describe("AccordionSectionHeader", () => { caretPosition="end" cornerKind="square" expanded={false} + animated={false} onClick={() => {}} sectionContentUniqueId="section-content-unique-id" isFirstSection={false} @@ -59,6 +61,7 @@ describe("AccordionSectionHeader", () => { caretPosition="end" cornerKind="square" expanded={false} + animated={false} onClick={() => {}} sectionContentUniqueId="section-content-unique-id" isFirstSection={false} @@ -81,6 +84,7 @@ describe("AccordionSectionHeader", () => { caretPosition="end" cornerKind="square" expanded={false} + animated={false} onClick={onClickSpy} sectionContentUniqueId="section-content-unique-id" isFirstSection={false} @@ -92,4 +96,54 @@ describe("AccordionSectionHeader", () => { // Assert expect(onClickSpy).toHaveBeenCalledTimes(1); }); + + test("includes transition styles when animated is true", () => { + // Arrange + render( + {}} + sectionContentUniqueId="section-content-unique-id" + isFirstSection={false} + isLastSection={false} + />, + ); + + // Act + const header = screen.getByRole("button"); + + // Assert + expect(header).toHaveStyle({ + transition: "border-radius 300ms", + }); + }); + + test("does not include transition styles when animated is false", () => { + // Arrange + render( + {}} + sectionContentUniqueId="section-content-unique-id" + isFirstSection={false} + isLastSection={false} + />, + ); + + // Act + const header = screen.getByRole("button"); + + // Assert + expect(header).not.toHaveStyle({ + transition: "border-radius 300ms", + }); + }); }); diff --git a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx index 8049a792d..ce3419f19 100644 --- a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx +++ b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx @@ -17,7 +17,7 @@ describe("AccordionSection", () => { // Assert expect(screen.getByText("Title")).toBeVisible(); - expect(screen.queryByText("Section content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section content")).not.toBeVisible(); }); test("renders with open panel when expanded is true", () => { @@ -114,7 +114,7 @@ describe("AccordionSection", () => { // Assert // Make sure the section has closed after clicking - expect(screen.queryByText("Section content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section content")).not.toBeVisible(); // Repeat clicking to confirm behavior button.click(); expect(screen.getByText("Section content")).toBeVisible(); @@ -129,7 +129,7 @@ describe("AccordionSection", () => { // Act // Make sure the section is closed at first - expect(screen.queryByText("Section content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section content")).not.toBeVisible(); const button = screen.getByRole("button", {name: "Title"}); button.click(); @@ -139,7 +139,7 @@ describe("AccordionSection", () => { expect(screen.getByText("Section content")).toBeVisible(); // Repeat clicking to confirm behavior button.click(); - expect(screen.queryByText("Section content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section content")).not.toBeVisible(); }); test("is h2 by default", () => { @@ -260,4 +260,58 @@ describe("AccordionSection", () => { "border-radius": "12px", }); }); + + test("includes transition when animated is true", () => { + // Arrange + render( + + Section content + , + {wrapper: RenderStateRoot}, + ); + + // Act + const wrapper = screen.getByTestId("accordion-section-test-id"); + const header = screen.getByTestId("accordion-section-header"); + + // Assert + expect(wrapper).toHaveStyle({ + transition: "grid-template-rows 300ms", + }); + expect(header).toHaveStyle({ + transition: "border-radius 300ms", + }); + }); + + test("does not include transition when animated is false", () => { + // Arrange + render( + + Section content + , + {wrapper: RenderStateRoot}, + ); + + // Act + const wrapper = screen.getByTestId("accordion-section-test-id"); + const header = screen.getByTestId("accordion-section-header"); + + // Assert + expect(wrapper).not.toHaveStyle({ + transition: "grid-template-rows 300ms", + }); + expect(header).not.toHaveStyle({ + transition: "border-radius 300ms", + }); + }); }); diff --git a/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx b/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx index 2fcd7ff7a..854c8416a 100644 --- a/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx +++ b/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx @@ -80,8 +80,8 @@ describe("Accordion", () => { button2.click(); // Assert - expect(screen.queryByText("Section 1 content")).not.toBeInTheDocument(); - expect(screen.queryByText("Section 2 content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section 1 content")).not.toBeVisible(); + expect(screen.queryByText("Section 2 content")).not.toBeVisible(); }); test("initialExpandedIndex opens the correct section", () => { @@ -103,9 +103,9 @@ describe("Accordion", () => { // Act // Assert - expect(screen.queryByText("Section 1 content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section 1 content")).not.toBeVisible(); expect(screen.getByText("Section 2 content")).toBeVisible(); - expect(screen.queryByText("Section 3 content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section 3 content")).not.toBeVisible(); }); test("only allows one section to be open at a time when allowMultipleExpanded is false", () => { @@ -130,8 +130,8 @@ describe("Accordion", () => { button.click(); // Assert - expect(screen.queryByText("Section 1 content")).not.toBeInTheDocument(); - expect(screen.queryByText("Section 2 content")).not.toBeInTheDocument(); + expect(screen.queryByText("Section 1 content")).not.toBeVisible(); + expect(screen.queryByText("Section 2 content")).not.toBeVisible(); expect(screen.getByText("Section 3 content")).toBeVisible(); }); @@ -359,6 +359,36 @@ describe("Accordion", () => { }); }); + test("prioritizes the parent's animated prop", () => { + // Arrange + render( + + {[ + + Section content + , + ]} + , + {wrapper: RenderStateRoot}, + ); + + // Act + const sectionHeader = screen.getByTestId("section-header-test-id"); + + // Assert + // The parent has animated=true, so the child's animated=false + // should be overridden. + expect(sectionHeader).toHaveStyle({ + // The existence of the transition style means that the + // accordion is animated. + transition: "border-radius 300ms", + }); + }); + test("applies style to the wrapper", () => { // Arrange render( diff --git a/packages/wonder-blocks-accordion/src/components/accordion-section-header.tsx b/packages/wonder-blocks-accordion/src/components/accordion-section-header.tsx index 44e82a70a..b28262b33 100644 --- a/packages/wonder-blocks-accordion/src/components/accordion-section-header.tsx +++ b/packages/wonder-blocks-accordion/src/components/accordion-section-header.tsx @@ -22,6 +22,9 @@ type Props = { cornerKind: AccordionCornerKindType; // Whether the section is expanded or not. expanded: boolean; + // Whether to include animation on the header. This should be false + // if the user has `prefers-reduced-motion` opted in. Defaults to false. + animated: boolean; // Called on header click. onClick?: () => void; // The ID for the content that the header's `aria-controls` should @@ -48,6 +51,7 @@ const AccordionSectionHeader = (props: Props) => { caretPosition, cornerKind, expanded, + animated, onClick, sectionContentUniqueId, headerStyle, @@ -75,6 +79,7 @@ const AccordionSectionHeader = (props: Props) => { testId={testId} style={[ styles.headerWrapper, + animated && styles.headerWrapperWithAnimation, caretPosition === "start" && styles.headerWrapperCaretStart, roundedTop && styles.roundedTop, roundedBottom && styles.roundedBottom, @@ -108,6 +113,7 @@ const AccordionSectionHeader = (props: Props) => { color={tokens.color.offBlack64} size="small" style={[ + animated && styles.iconWithAnimation, caretPosition === "start" ? styles.iconStart : styles.iconEnd, @@ -126,6 +132,7 @@ const AccordionSectionHeader = (props: Props) => { // a 1px gap between the border and the outline. To fix this, we // subtract 1 from the border radius. const INNER_BORDER_RADIUS = tokens.spacing.small_12 - 1; +const ANIMATION_LENGTH = "300ms"; const styles = StyleSheet.create({ heading: { @@ -149,6 +156,9 @@ const styles = StyleSheet.create({ outline: `2px solid ${tokens.color.blue}`, }, }, + headerWrapperWithAnimation: { + transition: `border-radius ${ANIMATION_LENGTH}`, + }, headerWrapperCaretStart: { flexDirection: "row-reverse", }, @@ -180,6 +190,9 @@ const styles = StyleSheet.create({ paddingInlineEnd: tokens.spacing.medium_16, paddingInlineStart: tokens.spacing.small_12, }, + iconWithAnimation: { + transition: `transform ${ANIMATION_LENGTH}`, + }, iconExpanded: { // Turn the caret upside down transform: "rotate(180deg)", diff --git a/packages/wonder-blocks-accordion/src/components/accordion-section.tsx b/packages/wonder-blocks-accordion/src/components/accordion-section.tsx index 46e530165..dc3894b11 100644 --- a/packages/wonder-blocks-accordion/src/components/accordion-section.tsx +++ b/packages/wonder-blocks-accordion/src/components/accordion-section.tsx @@ -61,6 +61,15 @@ type Props = AriaProps & { * manages the expanded state of the AccordionSection. */ expanded?: boolean; + /** + * Whether to include animation on the header. This should be false + * if the user has `prefers-reduced-motion` opted in. Defaults to false. + * + * If this prop is specified both here in the AccordionSection and + * within a parent Accordion component, the Accordion’s animated + * value is prioritized. + */ + animated?: boolean; /** * Called when the header is clicked. * Takes the new expanded state as an argument. This way, the function @@ -108,7 +117,7 @@ type Props = AriaProps & { /** * An AccordionSection displays a section of content that can be shown or * hidden by clicking its header. This is generally used within the Accordion - * component, but it can also be used on its own if you need only + * component, but it can also be used on its own if you need only one * collapsible section. * * ### Usage @@ -132,7 +141,7 @@ type Props = AriaProps & { * * * - * // On its own + * // On its own, controlled * const [expanded, setExpanded] = React.useState(false); * * This is the information present in the standalone section * + * + * // On its own, uncontrolled + * + * This is the information present in the standalone section + * * ``` */ const AccordionSection = React.forwardRef(function AccordionSection( @@ -152,6 +166,7 @@ const AccordionSection = React.forwardRef(function AccordionSection( id, header, expanded, + animated = false, onToggle, caretPosition = "end", cornerKind = "rounded", @@ -209,7 +224,15 @@ const AccordionSection = React.forwardRef(function AccordionSection( return ( - {/* The content is the section that expands and closes. */} - {expandedState ? ( - - {typeof children === "string" ? ( - {children} - ) : ( - children - )} - - ) : null} + + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + ); }); const styles = StyleSheet.create({ wrapper: { - flexDirection: "column", + // Use grid layout for clean animations. + display: "grid", boxSizing: "border-box", }, + wrapperWithAnimation: { + transition: "grid-template-rows 300ms", + }, + wrapperCollapsed: { + gridTemplateRows: "min-content 0fr", + }, + wrapperExpanded: { + gridTemplateRows: "min-content 1fr", + }, contentWrapper: { + overflow: "hidden", + }, + conentWrapperCollapsed: { + // Make sure screen readers don't read the content when it's + // collapsed. + visibility: "hidden", + }, + contentWrapperExpanded: { + visibility: "visible", // Add a small margin to the top of the content block so that the // header outline doesn't overlap with the content (and the content // doesn't overlap with the header outline). @@ -307,7 +350,6 @@ const _generateStyles = ( // because it cuts off the header's focus outline. borderEndEndRadius: tokens.spacing.small_12, borderEndStartRadius: tokens.spacing.small_12, - overflow: "hidden", }; if (isFirstSection) { @@ -339,7 +381,6 @@ const _generateStyles = ( // because it cuts off the header's focus outline. borderEndEndRadius: tokens.spacing.small_12, borderEndStartRadius: tokens.spacing.small_12, - overflow: "hidden", }; } diff --git a/packages/wonder-blocks-accordion/src/components/accordion.tsx b/packages/wonder-blocks-accordion/src/components/accordion.tsx index 76bf3471b..770e8ed07 100644 --- a/packages/wonder-blocks-accordion/src/components/accordion.tsx +++ b/packages/wonder-blocks-accordion/src/components/accordion.tsx @@ -60,6 +60,15 @@ type Props = AriaProps & { * value is prioritized. */ cornerKind?: AccordionCornerKindType; + /** + * Whether to include animation on the header. This should be false + * if the user has `prefers-reduced-motion` opted in. Defaults to false. + * + * If this prop is specified both here in the Accordion and within + * a child AccordionSection component, the Accordion’s animated + * value is prioritized. + */ + animated?: boolean; /** * Custom styles for the overall accordion container. */ @@ -106,6 +115,7 @@ const Accordion = React.forwardRef(function Accordion( allowMultipleExpanded = true, caretPosition, cornerKind = "rounded", + animated, style, ...ariaProps } = props; @@ -147,6 +157,7 @@ const Accordion = React.forwardRef(function Accordion( caretPosition: childCaretPosition, cornerKind: childCornerKind, onToggle: childOnToggle, + animated: childanimated, } = child.props; const isFirstChild = index === 0; @@ -165,6 +176,8 @@ const Accordion = React.forwardRef(function Accordion( // Don't use the AccordionSection's expanded prop // when it's rendered within Accordion. expanded: sectionsOpened[index], + // Prioritize the Accordion's animated + animated: animated ?? childanimated, onToggle: () => handleSectionClick(index, childOnToggle), isFirstSection: isFirstChild,