aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/collapsible
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/collapsible')
-rw-r--r--src/components/molecules/collapsible/collapsible.module.scss100
-rw-r--r--src/components/molecules/collapsible/collapsible.stories.tsx72
-rw-r--r--src/components/molecules/collapsible/collapsible.test.tsx83
-rw-r--r--src/components/molecules/collapsible/collapsible.tsx108
-rw-r--r--src/components/molecules/collapsible/index.ts1
5 files changed, 364 insertions, 0 deletions
diff --git a/src/components/molecules/collapsible/collapsible.module.scss b/src/components/molecules/collapsible/collapsible.module.scss
new file mode 100644
index 0000000..3c5a97c
--- /dev/null
+++ b/src/components/molecules/collapsible/collapsible.module.scss
@@ -0,0 +1,100 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+
+.heading {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+ width: 100%;
+ padding: var(--spacing-2xs);
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--color-bg);
+ border-top: fun.convert-px(2) solid var(--color-primary-dark);
+ border-bottom: fun.convert-px(2) solid var(--color-primary-dark);
+
+ &:hover,
+ &:focus {
+ .icon {
+ background: var(--color-primary-light);
+ color: var(--color-fg-inverted);
+ transform: scale(1.25);
+
+ &::before,
+ &::after {
+ background: var(--color-bg);
+ }
+ }
+ }
+}
+
+.body {
+ &--has-borders {
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ }
+
+ &--has-padding {
+ padding-inline: var(--spacing-2xs);
+ }
+}
+
+.wrapper {
+ display: flex;
+ flex-flow: column;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("lg") {
+ max-height: calc(100vh - var(--spacing-2xs));
+
+ &--expanded {
+ .body {
+ overflow: hidden;
+ }
+
+ &:hover,
+ &:focus-within {
+ .body {
+ overflow-y: auto;
+ }
+ }
+ }
+ }
+ }
+
+ &--collapsed {
+ .body {
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ visibility: hidden;
+ transition:
+ all 0.2s linear 0.2s,
+ max-height 0.5s cubic-bezier(0, 1, 0, 1);
+ }
+ }
+
+ &--expanded {
+ .body {
+ max-height: 10000px; // Fixed value needed for transition.
+ opacity: 1;
+ visibility: visible;
+ transition:
+ all 0.5s ease-in-out 0s,
+ margin 0.2s ease-in-out 0s,
+ padding 0.2s ease-in-out 0s,
+ max-height 1.2s ease-in-out;
+
+ &--has-padding {
+ margin: var(--spacing-2xs) 0;
+ padding-block: var(--spacing-2xs);
+ }
+
+ &--no-padding {
+ margin: var(--spacing-xs) 0;
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/collapsible/collapsible.stories.tsx b/src/components/molecules/collapsible/collapsible.stories.tsx
new file mode 100644
index 0000000..7cac64d
--- /dev/null
+++ b/src/components/molecules/collapsible/collapsible.stories.tsx
@@ -0,0 +1,72 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Heading } from '../../atoms';
+import { Collapsible } from './collapsible';
+
+/**
+ * HeadingButton - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Collapsible',
+ component: Collapsible,
+ argTypes: {
+ heading: {
+ control: {
+ type: 'text',
+ },
+ description: 'Define the collapsible heading.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ isCollapsed: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Define if the component should be collapsed or expanded.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Collapsible>;
+
+const Template: ComponentStory<typeof Collapsible> = ({ heading, ...args }) => (
+ <Collapsible
+ {...args}
+ heading={
+ <Heading isFake level={3}>
+ {heading}
+ </Heading>
+ }
+ />
+);
+
+const heading = 'Your title';
+const body =
+ 'Eius et eum ex voluptas laboriosam aliquid quas necessitatibus. Molestiae eius voluptatem qui voluptas eaque et totam. Ut ipsum ea sit. Quos molestiae id est consequatur. Suscipit illo at. Omnis non suscipit. Qui itaque laboriosam quos ut est laudantium. Iusto recusandae excepturi quia labore voluptatem quod recusandae. Quod ducimus ut rem dolore et.';
+
+/**
+ * Collapsible Stories - Collapsed
+ */
+export const Collapsed = Template.bind({});
+Collapsed.args = {
+ children: body,
+ heading,
+ isCollapsed: true,
+};
+
+/**
+ * Collapsible Stories - Expanded
+ */
+export const Expanded = Template.bind({});
+Expanded.args = {
+ children: body,
+ heading,
+ isCollapsed: false,
+};
diff --git a/src/components/molecules/collapsible/collapsible.test.tsx b/src/components/molecules/collapsible/collapsible.test.tsx
new file mode 100644
index 0000000..52fbdd0
--- /dev/null
+++ b/src/components/molecules/collapsible/collapsible.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { Collapsible } from './collapsible';
+import { Heading } from 'src/components/atoms';
+
+const body =
+ 'Error autem numquam vero quo cum qui voluptatem est qui. Quasi id rem molestiae. Velit voluptatum dolores et officia. Ut voluptatem quae ut quaerat vel nulla.';
+const heading = 'sed minima sed';
+
+describe('Collapsible', () => {
+ it('renders the collapsible heading and body', () => {
+ const headingLevel = 2;
+
+ render(
+ <Collapsible heading={<Heading level={headingLevel}>{heading}</Heading>}>
+ {body}
+ </Collapsible>
+ );
+
+ expect(
+ rtlScreen.getByRole('heading', { level: headingLevel })
+ ).toHaveTextContent(heading);
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+
+ it('can be collapsed by default', () => {
+ render(
+ <Collapsible heading={<Heading level={3}>{heading}</Heading>} isCollapsed>
+ {body}
+ </Collapsible>
+ );
+
+ expect(rtlScreen.getByRole('button').parentElement).toHaveClass(
+ 'wrapper--collapsed'
+ );
+ // Neither toBeVisible or toHaveStyle are working.
+ // expect(rtlScreen.getByText(body)).toHaveStyle({ visibility: 'hidden' });
+ // expect(rtlScreen.getByText(body)).not.toBeVisible();
+ });
+
+ it('can be expanded by default', () => {
+ render(
+ <Collapsible
+ heading={<Heading level={3}>{heading}</Heading>}
+ isCollapsed={false}
+ >
+ {body}
+ </Collapsible>
+ );
+
+ expect(rtlScreen.getByRole('button').parentElement).toHaveClass(
+ 'wrapper--expanded'
+ );
+ expect(rtlScreen.getByText(body)).toBeVisible();
+ });
+
+ it('can be collapsed and/or expanded by the user', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <Collapsible heading={<Heading level={3}>{heading}</Heading>}>
+ {body}
+ </Collapsible>
+ );
+
+ expect(rtlScreen.getByRole('button').parentElement).toHaveClass(
+ 'wrapper--expanded'
+ );
+
+ await user.click(rtlScreen.getByRole('button', { name: heading }));
+
+ expect(rtlScreen.getByRole('button').parentElement).toHaveClass(
+ 'wrapper--collapsed'
+ );
+
+ await user.click(rtlScreen.getByRole('button', { name: heading }));
+
+ expect(rtlScreen.getByRole('button').parentElement).toHaveClass(
+ 'wrapper--expanded'
+ );
+ });
+});
diff --git a/src/components/molecules/collapsible/collapsible.tsx b/src/components/molecules/collapsible/collapsible.tsx
new file mode 100644
index 0000000..e61ccba
--- /dev/null
+++ b/src/components/molecules/collapsible/collapsible.tsx
@@ -0,0 +1,108 @@
+import {
+ useCallback,
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ type ReactNode,
+ forwardRef,
+ useId,
+ useState,
+} from 'react';
+import { Button, Icon } from '../../atoms';
+import styles from './collapsible.module.scss';
+
+export type CollapsibleProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children'
+> & {
+ /**
+ * The collapsible body.
+ */
+ children: ReactNode;
+ /**
+ * Should we disable padding around body?
+ *
+ * @default false
+ */
+ disablePadding?: boolean;
+ /**
+ * Should the body be bordered?
+ *
+ * @default false
+ */
+ hasBorders?: boolean;
+ /**
+ * The collapsible heading.
+ */
+ heading: ReactNode;
+ /**
+ * Should the component be collapsed or expanded by default?
+ *
+ * @default false
+ */
+ isCollapsed?: boolean;
+};
+
+const CollapsibleWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ CollapsibleProps
+> = (
+ {
+ children,
+ className = '',
+ disablePadding = false,
+ hasBorders = false,
+ heading,
+ isCollapsed = false,
+ ...props
+ },
+ ref
+) => {
+ const bodyId = useId();
+ const [isExpanded, setIsExpanded] = useState(!isCollapsed);
+ const bodyClassNames = [
+ styles.body,
+ hasBorders ? styles['body--has-borders'] : '',
+ styles[disablePadding ? 'body--no-padding' : 'body--has-padding'],
+ ];
+ const wrapperClassNames = [
+ styles.wrapper,
+ styles[isExpanded ? 'wrapper--expanded' : 'wrapper--collapsed'],
+ className,
+ ];
+
+ const handleState = useCallback(() => {
+ setIsExpanded((prevState) => !prevState);
+ }, []);
+
+ return (
+ <div {...props} className={wrapperClassNames.join(' ')} ref={ref}>
+ <Button
+ aria-controls={bodyId}
+ aria-expanded={isExpanded}
+ className={styles.heading}
+ // eslint-disable-next-line react/jsx-no-literals -- Kind allowed
+ kind="neutral"
+ onClick={handleState}
+ // eslint-disable-next-line react/jsx-no-literals -- Shape allowed
+ shape="initial"
+ >
+ {heading}
+ <Icon
+ aria-hidden
+ className={styles.icon}
+ shape={isExpanded ? 'minus' : 'plus'}
+ />
+ </Button>
+ <div className={bodyClassNames.join(' ')} id={bodyId}>
+ {children}
+ </div>
+ </div>
+ );
+};
+
+/**
+ * Collapsible component.
+ *
+ * Render a heading associated to a collapsible body.
+ */
+export const Collapsible = forwardRef(CollapsibleWithRef);
diff --git a/src/components/molecules/collapsible/index.ts b/src/components/molecules/collapsible/index.ts
new file mode 100644
index 0000000..20c0813
--- /dev/null
+++ b/src/components/molecules/collapsible/index.ts
@@ -0,0 +1 @@
+export * from './collapsible';