diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d69e377..6fe38c591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Implement `Collapsible`, and `CardCollapsible` core components + ## [1.0.26] - 2024-05-06 ### Added diff --git a/src/core/components/accordion/index.tsx b/src/core/components/accordion/index.ts similarity index 100% rename from src/core/components/accordion/index.tsx rename to src/core/components/accordion/index.ts diff --git a/src/core/components/cards/cardCollapsible/cardCollapsible.stories.tsx b/src/core/components/cards/cardCollapsible/cardCollapsible.stories.tsx new file mode 100644 index 000000000..f95ae5b73 --- /dev/null +++ b/src/core/components/cards/cardCollapsible/cardCollapsible.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { CardCollapsible } from './cardCollapsible'; + +/** + * CardCollapsible component that can wrap any content and visually collapse it for space-saving purposes. + */ +const meta: Meta = { + title: 'Core/Components/Cards/CardCollapsible', + component: CardCollapsible, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?node-id=10157-27011&t=RVJHJFTrLMnhgYnJ-4', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the CardCollapsible component. + */ +export const Default: Story = { + args: { buttonLabelClosed: 'Read more', buttonLabelOpened: 'Read less' }, + render: (args) => ( + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, + consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec + sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt + scelerisque. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, + consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec + sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt + scelerisque.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur + tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam + nec sapien nec turpis tincidunt scelerisque. +

+
+ ), +}; + +/** + * CardCollapsible component with an image as the content. + */ +export const WithImage: Story = { + args: { + buttonLabelClosed: 'See more', + buttonLabelOpened: 'See less', + }, + render: (args) => ( + + A beautiful landscape + + ), +}; + +export default meta; diff --git a/src/core/components/cards/cardCollapsible/cardCollapsible.test.tsx b/src/core/components/cards/cardCollapsible/cardCollapsible.test.tsx new file mode 100644 index 000000000..0ac88a3de --- /dev/null +++ b/src/core/components/cards/cardCollapsible/cardCollapsible.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import { CardCollapsible, type ICardCollapsibleProps } from './cardCollapsible'; + +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps = { ...props }; + + return ; + }; + + it('renders without crashing', () => { + const children = 'Content of the card'; + render(createTestComponent({ children })); + expect(screen.getByText('Content of the card')).toBeInTheDocument(); + }); + + it('forwards props to the Collapsible component', () => { + const defaultOpen = true; + const buttonLabelOpened = 'Close'; + const buttonLabelClosed = 'Open'; + jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(500); + + render(createTestComponent({ buttonLabelOpened, defaultOpen })); + expect(screen.queryByText(buttonLabelClosed)).not.toBeInTheDocument(); + expect(screen.getByText(buttonLabelOpened)).toBeInTheDocument(); + }); +}); diff --git a/src/core/components/cards/cardCollapsible/cardCollapsible.tsx b/src/core/components/cards/cardCollapsible/cardCollapsible.tsx new file mode 100644 index 000000000..4852310bf --- /dev/null +++ b/src/core/components/cards/cardCollapsible/cardCollapsible.tsx @@ -0,0 +1,22 @@ +import classNames from 'classnames'; +import { Collapsible, type ICollapsibleProps } from '../../collapsible'; +import { Card } from '../card'; + +export interface ICardCollapsibleProps extends Omit { + /** + * Additional class names to apply to the card. + */ + className?: string; +} + +export const CardCollapsible: React.FC = (props) => { + const { children, className, ...otherProps } = props; + + return ( + + + {children} + + + ); +}; diff --git a/src/core/components/cards/cardCollapsible/index.ts b/src/core/components/cards/cardCollapsible/index.ts new file mode 100644 index 000000000..52c46498f --- /dev/null +++ b/src/core/components/cards/cardCollapsible/index.ts @@ -0,0 +1 @@ +export { CardCollapsible, type ICardCollapsibleProps } from './cardCollapsible'; diff --git a/src/core/components/cards/index.ts b/src/core/components/cards/index.ts index a3cfffae0..b051c6690 100644 --- a/src/core/components/cards/index.ts +++ b/src/core/components/cards/index.ts @@ -1,3 +1,4 @@ export * from './card'; +export * from './cardCollapsible'; export * from './cardEmptyState'; export * from './cardSummary'; diff --git a/src/core/components/collapsible/collapsible.api.ts b/src/core/components/collapsible/collapsible.api.ts new file mode 100644 index 000000000..b2d24e17d --- /dev/null +++ b/src/core/components/collapsible/collapsible.api.ts @@ -0,0 +1,38 @@ +import { type ComponentProps } from 'react'; + +export type CollapsedSize = 'sm' | 'md' | 'lg'; + +export interface ICollapsibleProps extends ComponentProps<'div'> { + /** + * The initial height of the collapsible container while closed. @default md + */ + collapsedSize?: CollapsedSize; + /** + * Custom pixel height for the collapsible container that will override collapsedSize prop if defined. + */ + customCollapsedHeight?: number; + /** + * Controlled state of the collapsible container. @default false + */ + isOpen?: boolean; + /** + * Default state of the collapsible container. @default false + */ + defaultOpen?: boolean; + /** + * The label to display on the trigger button when the collapsible container is closed. + */ + buttonLabelClosed?: string; + /** + * The label to display on the trigger button when the collapsible container is open. + */ + buttonLabelOpened?: string; + /** + * Show overlay when the collapsible container is open. @default false + */ + showOverlay?: boolean; + /** + * Callback function that is called when the collapsible container is toggled. + */ + onToggle?: (isOpen: boolean) => void; +} diff --git a/src/core/components/collapsible/collapsible.stories.tsx b/src/core/components/collapsible/collapsible.stories.tsx new file mode 100644 index 000000000..b8e5c1578 --- /dev/null +++ b/src/core/components/collapsible/collapsible.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Collapsible } from './collapsible'; + +/** + * Collapsible component that can wrap any content and visually collapse it for space-saving purposes. + */ +const meta: Meta = { + title: 'Core/Components/Collapsible', + component: Collapsible, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?node-id=10157-27011&t=RVJHJFTrLMnhgYnJ-4', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the Collapsible component. + */ +export const Default: Story = { + args: { buttonLabelClosed: 'Read more', buttonLabelOpened: 'Read less' }, + render: (args) => ( + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, + consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec + sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt + scelerisque. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien + nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla + nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. + Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, + consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec + sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt + scelerisque.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur + tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. Nullam + nec sapien nec turpis tincidunt scelerisque. +

+
+ ), +}; + +/** + * Collapsible component with a short text as the content to show overflow detection. + */ +export const ShortContent: Story = { + args: { buttonLabelClosed: 'Read more', buttonLabelOpened: 'Read less' }, + render: (args) => ( + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. + Nulla facilisi. +

+
+ ), +}; + +/** + * Collapsible component with an image as the content with defaultOpen true. + */ +export const WithImage: Story = { + args: { + buttonLabelClosed: 'See more', + buttonLabelOpened: 'See less', + defaultOpen: true, + }, + render: (args) => ( + + A beautiful landscape + + ), +}; + +/** + * Controlled usage example of the Collapsible component. + */ +export const Controlled: Story = { + args: { + buttonLabelOpened: 'Collapse content', + buttonLabelClosed: 'Expand content', + collapsedSize: 'sm', + }, + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleToggle = (toggle: boolean) => { + setIsOpen(toggle); + }; + + return ( + +

+ This is some example content within the Collapsible component. When expanded, the content will be + fully visible. +

+
+

+ Controlled usage ensures that the parent controls the open and closed states and updates the + component accordingly. +

+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc consectetur + tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla facilisi. + Nullam nec sapien nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, consectetur + adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec + turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt + scelerisque.Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ac nulla nec nunc + consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. Nulla + facilisi. Nullam nec sapien nec turpis tincidunt scelerisque.Lorem ipsum dolor sit amet, consectetur + adipiscing elit. Sed ac nulla nec nunc consectetur tincidunt. Nulla facilisi. Nullam nec sapien nec + turpis tincidunt scelerisque. Nulla facilisi. Nullam nec sapien nec turpis tincidunt scelerisque. +

+
+ ); + }, +}; + +export default meta; diff --git a/src/core/components/collapsible/collapsible.test.tsx b/src/core/components/collapsible/collapsible.test.tsx new file mode 100644 index 000000000..a82bea688 --- /dev/null +++ b/src/core/components/collapsible/collapsible.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { Collapsible } from './collapsible'; +import { type ICollapsibleProps } from './collapsible.api'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps = { ...props }; + + return ; + }; + + let originalResizeObserver: typeof global.ResizeObserver; + + beforeAll(() => { + originalResizeObserver = global.ResizeObserver; + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + afterAll(() => { + global.ResizeObserver = originalResizeObserver; + }); + + beforeEach(() => { + jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(500); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders without crashing', () => { + const children = 'Default Children'; + render(createTestComponent({ children })); + + expect(screen.getByText('Default Children')).toBeInTheDocument(); + }); + + it('uses default collapsedSize on collapsible content', () => { + const children = 'Default Children'; + render(createTestComponent({ children })); + + const content = screen.getByText('Default Children'); + expect(content.style.maxHeight).toBe('256px'); + }); + + it('applies customCollapsedHeight correctly', () => { + const children = 'Default Children'; + const customCollapsedHeight = 150; + render(createTestComponent({ children, customCollapsedHeight })); + + const content = screen.getByText('Default Children'); + expect(content.style.maxHeight).toBe('150px'); + }); + + it('handles non-overflowing content correctly', () => { + const children = 'Default Children'; + const customCollapsedHeight = 300; + const buttonLabelOpened = 'Open'; + const buttonLabelClosed = 'Closed'; + jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200); + render(createTestComponent({ children, customCollapsedHeight, buttonLabelClosed, buttonLabelOpened })); + const content = screen.getByText('Default Children'); + expect(content.style.maxHeight).toBe('300px'); + expect(screen.queryByText(buttonLabelOpened)).not.toBeInTheDocument(); + expect(screen.queryByText(buttonLabelClosed)).not.toBeInTheDocument(); + }); + + it('toggles opened/closed state when button is clicked', async () => { + const buttonLabelOpened = 'Open'; + const buttonLabelClosed = 'Closed'; + + render(createTestComponent({ buttonLabelOpened, buttonLabelClosed })); + + const button = screen.getByText('Closed'); + await userEvent.click(button); + expect(button.textContent).toBe('Open'); + await userEvent.click(button); + expect(button.textContent).toBe('Closed'); + }); + + it('renders open when defaultOpen is true', () => { + const children = 'Default Children'; + const defaultOpen = true; + const buttonLabelOpened = 'Open'; + const buttonLabelClosed = 'Closed'; + render(createTestComponent({ children, buttonLabelOpened, buttonLabelClosed, defaultOpen })); + const button = screen.getByRole('button'); + expect(button.textContent).toBe(buttonLabelOpened); + expect(button.textContent).not.toBe(buttonLabelClosed); + }); + + it('calls the onToggle callback with the new state', async () => { + const onToggle = jest.fn(); + render(createTestComponent({ onToggle })); + + const button = screen.getByRole('button'); + await userEvent.click(button); + expect(onToggle).toHaveBeenCalledWith(true); + await userEvent.click(button); + expect(onToggle).toHaveBeenCalledWith(false); + }); + + it('renders custom button labels', async () => { + const buttonLabelOpened = 'Collapse'; + const buttonLabelClosed = 'Expand'; + render(createTestComponent({ buttonLabelOpened, buttonLabelClosed })); + + expect(screen.getByText('Expand')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Expand')); + expect(screen.getByText('Collapse')).toBeInTheDocument(); + }); + + it('handles absence of buttonVariant using default button styles', async () => { + const buttonLabelOpened = 'Collapse'; + const buttonLabelClosed = 'Expand'; + render(createTestComponent({ buttonLabelOpened, buttonLabelClosed })); + + const button = screen.getByRole('button'); + + await userEvent.click(button); + expect(button).toHaveTextContent('Collapse'); + }); + + it('renders an overlay with proper button when showOverlay prop is set to true', () => { + const showOverlay = true; + render(createTestComponent({ showOverlay })); + const button = screen.getByRole('button'); + expect(button).toHaveClass('bg-neutral-0'); + }); +}); diff --git a/src/core/components/collapsible/collapsible.tsx b/src/core/components/collapsible/collapsible.tsx new file mode 100644 index 000000000..8c0bc2e92 --- /dev/null +++ b/src/core/components/collapsible/collapsible.tsx @@ -0,0 +1,114 @@ +import classNames from 'classnames'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '../button'; +import { Icon, IconType } from '../icon'; +import { type ICollapsibleProps } from './collapsible.api'; + +const sizedCollapsedHeights = { + sm: 128, + md: 256, + lg: 384, +}; + +export const Collapsible: React.FC = ({ + collapsedSize = 'md', + customCollapsedHeight, + isOpen: controlledIsOpen, + defaultOpen = false, + buttonLabelOpened, + buttonLabelClosed, + showOverlay = false, + className, + onToggle, + children, + ...otherProps +}) => { + const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen); + const isControlled = controlledIsOpen !== undefined; + const isOpen = isControlled ? controlledIsOpen : internalIsOpen; + const [isOverflowing, setIsOverflowing] = useState(false); + const contentRef = useRef(null); + const [maxHeight, setMaxHeight] = useState(); + const maxCollapsedHeight = customCollapsedHeight ?? sizedCollapsedHeights[collapsedSize]; + + const toggle = useCallback(() => { + const newIsOpen = !isOpen; + if (!isControlled) { + setInternalIsOpen(newIsOpen); + } + onToggle?.(newIsOpen); + }, [isOpen, isControlled, onToggle]); + + useEffect(() => { + const content = contentRef.current; + + const checkOverflow = () => { + if (content) { + const contentHeight = content.scrollHeight; + const isContentOverflowing = contentHeight > maxCollapsedHeight; + + setIsOverflowing(isContentOverflowing); + setMaxHeight(isContentOverflowing ? contentHeight : maxCollapsedHeight); + } + }; + + const observer = new ResizeObserver(() => checkOverflow()); + if (content) { + observer.observe(content); + } + + checkOverflow(); + + return () => { + observer.disconnect(); + }; + }, [maxCollapsedHeight]); + + const parsedMaxHeight = !isOpen ? `${maxCollapsedHeight}px` : `${maxHeight}px`; + + const outerClassName = classNames('relative', { 'bg-neutral-0': showOverlay }, className); + const contentClassNames = classNames( + 'overflow-hidden transition-all', // base + ); + + const footerClassName = classNames( + { 'left-0 z-10 flex w-full items-end bg-gradient-to-t from-neutral-0 from-40% to-transparent': showOverlay }, + { 'absolute bottom-0 h-28 md:h-32': !isOpen && showOverlay }, + { 'h-auto md:h-auto mt-4': isOpen && showOverlay }, + { 'mt-4': isOverflowing && !showOverlay }, + ); + + return ( +
+
+ {children} +
+ {isOverflowing && ( +
+ {showOverlay ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; diff --git a/src/core/components/collapsible/index.ts b/src/core/components/collapsible/index.ts new file mode 100644 index 000000000..7095a39e8 --- /dev/null +++ b/src/core/components/collapsible/index.ts @@ -0,0 +1,2 @@ +export { Collapsible } from './collapsible'; +export { type CollapsedSize, type ICollapsibleProps } from './collapsible.api';