From b8368746a1f36668377ba6f26cb26e98e2eda856 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Thu, 23 Nov 2023 16:00:29 +1300 Subject: [PATCH 1/3] accordion and tests --- package.json | 1 + src/components/Accordion/Accordion.module.css | 42 ++ .../Accordion/Accordion.stories.tsx | 83 ++++ src/components/Accordion/Accordion.test.tsx | 156 ++++++ src/components/Accordion/Accordion.tsx | 163 +++++++ src/components/Accordion/Item.stories.tsx | 63 +++ .../__snapshots__/Accordion.test.tsx.snap | 454 ++++++++++++++++++ yarn.lock | 31 ++ 8 files changed, 993 insertions(+) create mode 100644 src/components/Accordion/Accordion.module.css create mode 100644 src/components/Accordion/Accordion.stories.tsx create mode 100644 src/components/Accordion/Accordion.test.tsx create mode 100644 src/components/Accordion/Accordion.tsx create mode 100644 src/components/Accordion/Item.stories.tsx create mode 100644 src/components/Accordion/__snapshots__/Accordion.test.tsx.snap diff --git a/package.json b/package.json index 7c013d3a..e0195217 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ }, "dependencies": { "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.6", diff --git a/src/components/Accordion/Accordion.module.css b/src/components/Accordion/Accordion.module.css new file mode 100644 index 00000000..0ad51e55 --- /dev/null +++ b/src/components/Accordion/Accordion.module.css @@ -0,0 +1,42 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.trigger { + all: unset; + display: flex; + flex-direction: row; + align-items: start; + gap: var(--cpd-space-2x); + cursor: pointer; +} + +.trigger:focus { + outline: 2px solid var(--cpd-color-border-focused); + outline-offset: 1px; +} + +.icon { + transform: rotate(0); + transform-origin: center center; + transition: 300ms ease; + color: var(--cpd-color-icon-secondary); + + flex-shrink: 0; +} + +.trigger[aria-expanded="true"] .icon { + transform: rotate(90deg); +} diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 00000000..11fbf4a5 --- /dev/null +++ b/src/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { Meta } from "@storybook/react"; +import { Item } from "./Accordion"; + +import { Root as AccordionComponent } from "./Accordion"; + +const LOREM_IPSUM = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + +export default { + title: "Accordion", + component: AccordionComponent, + tags: ["autodocs"], + argTypes: { + onValueChange: { action: "onValueChange" }, + }, + args: { + children: ( + <> + + {LOREM_IPSUM} + + + {LOREM_IPSUM} + + + {LOREM_IPSUM} + + + ), + }, +} as Meta; + +export const Default = { + args: {}, + parameters: {}, +}; + +export const Single = { + args: { + type: "single", + }, + parameters: {}, +}; + +export const Multiple = { + args: { + type: "multiple", + }, + parameters: {}, +}; + +export const SingleControlled = { + args: { + value: "item1", + type: "single", + }, + parameters: {}, +}; + +export const MultipleControlled = { + args: { + value: ["item1", "item2"], + type: "multiple", + }, + parameters: {}, +}; diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx new file mode 100644 index 00000000..5f350c3b --- /dev/null +++ b/src/components/Accordion/Accordion.test.tsx @@ -0,0 +1,156 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; + +import * as stories from "./Accordion.stories"; +import { composeStories, composeStory } from "@storybook/react"; + +const { Default, Single, Multiple } = composeStories(stories); + +describe("", () => { + it("renders an uncontrolled single accordion by default", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders an uncontrolled single accordion", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + + fireEvent.click(screen.getByText("Item 1")); + + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "open", + ); + fireEvent.click(screen.getByText("Item 2")); + + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "closed", + ); + expect(screen.getByText("Item 2").getAttribute("data-state")).toEqual( + "open", + ); + }); + + it("renders an uncontrolled multiple accordion", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + + fireEvent.click(screen.getByText("Item 1")); + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "open", + ); + + fireEvent.click(screen.getByText("Item 2")); + // both open + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "open", + ); + expect(screen.getByText("Item 2").getAttribute("data-state")).toEqual( + "open", + ); + }); + + describe("single controlled", () => { + const onValueChange = vi.fn(); + // compose the story with our mocked onValueChange + const SingleControlled = composeStory( + { + ...stories.SingleControlled, + args: { + ...stories.SingleControlled.args, + onValueChange, + }, + }, + stories.default, + ); + + afterEach(() => { + onValueChange.mockClear(); + }); + + it("renders a controlled single accordion with an item open", () => { + render(); + + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "open", + ); + }); + + it("handles click on an expanded item", () => { + render(); + + fireEvent.click(screen.getByText("Item 1")); + expect(onValueChange).toHaveBeenCalledWith(""); + }); + + it("handles click on a collapsed item", () => { + render(); + + fireEvent.click(screen.getByText("Item 2")); + expect(onValueChange).toHaveBeenCalledWith("item2"); + }); + }); + + describe("multiple controlled", () => { + const onValueChange = vi.fn(); + // compose the story with our mocked onValueChange + const MultipleControlled = composeStory( + { + ...stories.MultipleControlled, + args: { + ...stories.MultipleControlled.args, + onValueChange, + }, + }, + stories.default, + ); + + afterEach(() => { + onValueChange.mockClear(); + }); + + it("renders a controlled single accordion with an item open", () => { + render(); + + expect(screen.getByText("Item 1").getAttribute("data-state")).toEqual( + "open", + ); + expect(screen.getByText("Item 2").getAttribute("data-state")).toEqual( + "open", + ); + }); + + it("handles click on an expanded item", () => { + render(); + + fireEvent.click(screen.getByText("Item 1")); + // item1 removed from array of open items + expect(onValueChange).toHaveBeenCalledWith(["item2"]); + }); + + it("handles click on a collapsed item", () => { + render(); + + fireEvent.click(screen.getByText("Item 3")); + // item3 added to array of open items + expect(onValueChange).toHaveBeenCalledWith(["item1", "item2", "item3"]); + }); + }); +}); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..218ea5d3 --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,163 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classNames from "classnames"; +import React, { forwardRef, PropsWithChildren, ReactNode } from "react"; +import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg"; +import * as RadixAccordion from "@radix-ui/react-accordion"; + +import styles from "./Accordion.module.css"; + +type SingleAccordionProps = { + /** + * Determines whether one or multiple items can be opened at the same time. + * Default is single + */ + type?: "single"; + /** + * The controlled value of the item(s) to expand. Must be used in conjunction with onValueChange. + * When value is undefined, Accordion will manage its own expanded state + * When type is single, should be a single string or null + * Whent type is multiple, should be an array of strings + */ + value?: string | null; + /** + * Event handler called when the expanded state of an item changes, + * Called with the suggested new `value`: + * - when collapsing the only expanded item will be called with an empty string or empty array + * - when type is multiple will be called with array of all open items + */ + onValueChange?: (value?: string) => void; +}; + +type MultipleAccordionProps = { + type?: "multiple"; + value?: string[]; + onValueChange?: (value?: string[]) => void; +}; + +/** + * See https://www.radix-ui.com/primitives/docs/components/accordion#root + */ +type AccordionProps = { + /** + * The CSS class name. + */ + className?: string; + + /** + * Whether all items can be closed + * Default is true + */ + collapsible?: boolean; +} & React.ComponentProps & + (SingleAccordionProps | MultipleAccordionProps); + +/** + * Thin wrapper around Radix UI Accordion component + * + * See radix documentation: https://www.radix-ui.com/primitives/docs/components/accordion + */ +export const Root = forwardRef(function Root( + { + children, + className, + type, + collapsible = true, + ...props + }: PropsWithChildren, + ref, +) { + // TODO: Update the class name to something related to the component name + const classes = classNames(styles.root, className); + return ( + + {children} + + ); +}); + +type TriggerProps = { + /** + * The CSS class name. + */ + className?: string; +} & React.ComponentProps; +const Trigger = forwardRef(function Trigger( + { children, className, ...props }: TriggerProps, + forwardedRef, +) { + return ( + + + {children} + + + + ); +}); + +type ItemProps = { + /** + * The CSS class name. + */ + className?: string; + /** + * Unique identifier for this item within the accordion + */ + value: string; + /** + * The element or text used as the header of the accordion + * Will be wrapped in a button element by Radix + */ + trigger: ReactNode | string; +} & React.ComponentProps; +export const Item = forwardRef(function Item( + { + value, + trigger, + children, + className, + ...props + }: PropsWithChildren, + ref, +) { + const classes = classNames(styles.root, className); + return ( + + {trigger} + + + {children} + + + ); +}); diff --git a/src/components/Accordion/Item.stories.tsx b/src/components/Accordion/Item.stories.tsx new file mode 100644 index 00000000..9a96fe13 --- /dev/null +++ b/src/components/Accordion/Item.stories.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import { Text } from "../Typography/Text"; +import { Root, Item } from "./Accordion"; + +const LOREM_IPSUM = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + +export default { + title: "Accordion/Item", + component: Item, + tags: ["autodocs"], + argTypes: {}, + args: { + trigger: ( + + Click me! + + ), + children: {LOREM_IPSUM}, + value: "item1", + }, + decorators: [ + (Story: StoryFn) => ( + + + + ), + ], +} as Meta; + +export const Default = { + args: {}, +}; + +export const LongTrigger = { + args: { + trigger: ( + + Charles "Charlie" Brown is the principal character of the + comic strip Peanuts, syndicated in daily and Sunday newspapers in + numerous countries all over the world. + + ), + }, +}; diff --git a/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap b/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap new file mode 100644 index 00000000..e44c30c8 --- /dev/null +++ b/src/components/Accordion/__snapshots__/Accordion.test.tsx.snap @@ -0,0 +1,454 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders an uncontrolled multiple accordion 1`] = ` +
+
+
+

+ +

+ +
+

+ +

+ +
+

+ +

+ +
+
+`; + +exports[` > renders an uncontrolled single accordion 1`] = ` +
+
+
+

+ +

+ +
+

+ +

+ +
+

+ +

+ +
+
+`; + +exports[` > renders an uncontrolled single accordion by default 1`] = ` +
+
+
+

+ +

+ +
+

+ +

+ +
+

+ +

+ +
+
+`; diff --git a/yarn.lock b/yarn.lock index 87e90e9d..2f34b5a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1721,6 +1721,22 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-accordion@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz#738441f7343e5142273cdef94d12054c3287966f" + integrity sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collapsible" "1.0.3" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-arrow@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" @@ -1729,6 +1745,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-collapsible@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" + integrity sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-collection@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159" From be3a362392f04c1269c774a194d093687812e3e9 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 28 Nov 2023 13:52:27 +1300 Subject: [PATCH 2/3] fix types --- src/components/Accordion/Accordion.module.css | 1 - .../Accordion/Accordion.stories.tsx | 1 + src/components/Accordion/Accordion.tsx | 53 ++----------------- src/components/Accordion/Item.stories.tsx | 2 +- 4 files changed, 5 insertions(+), 52 deletions(-) diff --git a/src/components/Accordion/Accordion.module.css b/src/components/Accordion/Accordion.module.css index 0ad51e55..20f697f3 100644 --- a/src/components/Accordion/Accordion.module.css +++ b/src/components/Accordion/Accordion.module.css @@ -33,7 +33,6 @@ limitations under the License. transform-origin: center center; transition: 300ms ease; color: var(--cpd-color-icon-secondary); - flex-shrink: 0; } diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx index 11fbf4a5..428b47ff 100644 --- a/src/components/Accordion/Accordion.stories.tsx +++ b/src/components/Accordion/Accordion.stories.tsx @@ -31,6 +31,7 @@ export default { onValueChange: { action: "onValueChange" }, }, args: { + collapsible: true, children: ( <> diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index 218ea5d3..515a569b 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -21,34 +21,6 @@ import * as RadixAccordion from "@radix-ui/react-accordion"; import styles from "./Accordion.module.css"; -type SingleAccordionProps = { - /** - * Determines whether one or multiple items can be opened at the same time. - * Default is single - */ - type?: "single"; - /** - * The controlled value of the item(s) to expand. Must be used in conjunction with onValueChange. - * When value is undefined, Accordion will manage its own expanded state - * When type is single, should be a single string or null - * Whent type is multiple, should be an array of strings - */ - value?: string | null; - /** - * Event handler called when the expanded state of an item changes, - * Called with the suggested new `value`: - * - when collapsing the only expanded item will be called with an empty string or empty array - * - when type is multiple will be called with array of all open items - */ - onValueChange?: (value?: string) => void; -}; - -type MultipleAccordionProps = { - type?: "multiple"; - value?: string[]; - onValueChange?: (value?: string[]) => void; -}; - /** * See https://www.radix-ui.com/primitives/docs/components/accordion#root */ @@ -57,14 +29,7 @@ type AccordionProps = { * The CSS class name. */ className?: string; - - /** - * Whether all items can be closed - * Default is true - */ - collapsible?: boolean; -} & React.ComponentProps & - (SingleAccordionProps | MultipleAccordionProps); +} & React.ComponentProps; /** * Thin wrapper around Radix UI Accordion component @@ -72,25 +37,13 @@ type AccordionProps = { * See radix documentation: https://www.radix-ui.com/primitives/docs/components/accordion */ export const Root = forwardRef(function Root( - { - children, - className, - type, - collapsible = true, - ...props - }: PropsWithChildren, + { children, className, ...props }: PropsWithChildren, ref, ) { // TODO: Update the class name to something related to the component name const classes = classNames(styles.root, className); return ( - + {children} ); diff --git a/src/components/Accordion/Item.stories.tsx b/src/components/Accordion/Item.stories.tsx index 9a96fe13..c9255cf0 100644 --- a/src/components/Accordion/Item.stories.tsx +++ b/src/components/Accordion/Item.stories.tsx @@ -39,7 +39,7 @@ export default { }, decorators: [ (Story: StoryFn) => ( - + ), From 14692a464f0929da1f00fd7d8418ab5815d7fea7 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 28 Nov 2023 13:59:44 +1300 Subject: [PATCH 3/3] export from index --- src/components/Accordion/index.ts | 17 +++++++++++++++++ src/index.ts | 1 + 2 files changed, 18 insertions(+) create mode 100644 src/components/Accordion/index.ts diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 00000000..c2d518de --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { Item, Root } from "./Accordion"; diff --git a/src/index.ts b/src/index.ts index 239e00d8..fab6537d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ limitations under the License. * Export React components */ +export * as Accordion from "./components/Accordion"; export { Alert } from "./components/Alert/Alert"; export { Avatar } from "./components/Avatar/Avatar"; export { AvatarStack } from "./components/Avatar/AvatarStack";