From b9d514e681781b756324151735687123918b81b4 Mon Sep 17 00:00:00 2001 From: adriancofie <38888889+adriancofie@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:25:03 -0400 Subject: [PATCH] (#62) Implement Collection component - Adds Collection and CollectionItem components with NCIDS usa-collection classes - Support configurable heading level, optional link, description, and children slot - Includes unit tests - Adds Storybook documentation - Updates pnpm-lock.yaml to lockfile v9 Closes #62 --- .storybook/preview.scss | 1 + .../ncids/Collection/Collection.stories.tsx | 136 +++++++++++ .../ncids/Collection/Collection.test.tsx | 221 ++++++++++++++++++ .../ncids/Collection/Collection.tsx | 77 ++++++ src/components/ncids/Collection/index.ts | 2 + src/components/ncids/index.ts | 2 + 6 files changed, 439 insertions(+) create mode 100644 src/components/ncids/Collection/Collection.stories.tsx create mode 100644 src/components/ncids/Collection/Collection.test.tsx create mode 100644 src/components/ncids/Collection/Collection.tsx create mode 100644 src/components/ncids/Collection/index.ts diff --git a/.storybook/preview.scss b/.storybook/preview.scss index 3c8ba78..92e0b24 100644 --- a/.storybook/preview.scss +++ b/.storybook/preview.scss @@ -7,3 +7,4 @@ @forward 'uswds-global'; @forward 'usa-pagination'; @forward 'usa-icon'; +@forward 'usa-collection'; diff --git a/src/components/ncids/Collection/Collection.stories.tsx b/src/components/ncids/Collection/Collection.stories.tsx new file mode 100644 index 0000000..859b99e --- /dev/null +++ b/src/components/ncids/Collection/Collection.stories.tsx @@ -0,0 +1,136 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import React from 'react'; + +import { Collection, CollectionItem } from './Collection'; + +const meta: Meta = { + title: 'NCIDS/Collection', + component: Collection, + tags: ['autodocs'], + argTypes: { + condensed: { + control: 'boolean', + description: 'Use condensed variant (headers only)', + }, + className: { + control: 'text', + description: 'Additional CSS classes on the list', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + ), +}; + +export const Condensed: Story = { + render: () => ( + + + + + + ), +}; + +export const WithExtraContent: Story = { + name: 'With Extra Content (Children)', + render: () => ( + + + https://www.cancer.gov/types/breast + + + https://www.cancer.gov/types/lung + + + ), +}; + +export const WithoutLinks: Story = { + name: 'Without Links (Plain Headings)', + render: () => ( + + + + + ), +}; + +export const CustomHeadingLevel: Story = { + name: 'Custom Heading Level (h4)', + render: () => ( + + + + ), +}; + +export const Interactive: Story = { + render: () => { + const handleClick = fn(); + return ( + + { + e.preventDefault(); + handleClick(); + }} + /> + + ); + }, +}; diff --git a/src/components/ncids/Collection/Collection.test.tsx b/src/components/ncids/Collection/Collection.test.tsx new file mode 100644 index 0000000..702374f --- /dev/null +++ b/src/components/ncids/Collection/Collection.test.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Collection, CollectionItem } from './Collection'; + +describe('', () => { + afterEach(() => { + cleanup(); + }); + + it('should render a ul with usa-collection class', () => { + const { container } = render( + + + + ); + const ul = container.querySelector('ul'); + expect(ul).toHaveClass('usa-collection'); + }); + + it('should apply usa-collection--condensed when condensed is true', () => { + const { container } = render( + + + + ); + const ul = container.querySelector('ul'); + expect(ul).toHaveClass('usa-collection', 'usa-collection--condensed'); + }); + + it('should not apply condensed class when condensed is false', () => { + const { container } = render( + + + + ); + const ul = container.querySelector('ul'); + expect(ul).not.toHaveClass('usa-collection--condensed'); + }); + + it('should pass additional className to Collection ul', () => { + const { container } = render( + + + + ); + const ul = container.querySelector('ul'); + expect(ul).toHaveClass('usa-collection', 'no-bullets', 'custom-class'); + }); +}); + +describe('', () => { + afterEach(() => { + cleanup(); + }); + + it('should render an li with usa-collection__item class', () => { + render( + + + + ); + const li = screen.getByRole('listitem'); + expect(li).toHaveClass('usa-collection__item'); + }); + + it('should render heading in h3 by default', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { level: 3, name: 'Test heading' }) + ).toBeInTheDocument(); + }); + + it('should render heading with h2 level', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { level: 2, name: 'Test heading' }) + ).toBeInTheDocument(); + }); + + it('should render heading with h4 level', () => { + render( + + + + ); + expect( + screen.getByRole('heading', { level: 4, name: 'Test heading' }) + ).toBeInTheDocument(); + }); + + it('should wrap heading in a link when href is provided', () => { + render( + + + + ); + const link = screen.getByRole('link', { name: 'Click me' }); + expect(link).toHaveClass('usa-link'); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); + + it('should render heading as plain text when no href', () => { + render( + + + + ); + expect(screen.getByText('Plain heading')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('should render description in a p element', () => { + const { container } = render( + + + + ); + const desc = container.querySelector('.usa-collection__description'); + expect(desc).toBeInTheDocument(); + expect(desc?.tagName).toBe('P'); + expect(desc).toHaveTextContent('A description here'); + }); + + it('should not render description p when no description prop', () => { + const { container } = render( + + + + ); + expect( + container.querySelector('.usa-collection__description') + ).not.toBeInTheDocument(); + }); + + it('should render children inside collection__body', () => { + const { container } = render( + + + https://example.com + + + ); + const body = container.querySelector('.usa-collection__body'); + const cite = body?.querySelector('cite.custom-cite'); + expect(cite).toBeInTheDocument(); + expect(cite).toHaveTextContent('https://example.com'); + }); + + it('should pass additional className to li element', () => { + render( + + + + ); + const li = screen.getByRole('listitem'); + expect(li).toHaveClass( + 'usa-collection__item', + 'sws-results__list-item', + 'grid-container' + ); + }); + + it('should call onHeadingClick when heading link is clicked', () => { + const handler = vi.fn(); + render( + + + + ); + fireEvent.click(screen.getByRole('link', { name: 'Clickable' })); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should apply usa-collection__heading class to heading element', () => { + const { container } = render( + + + + ); + expect( + container.querySelector('.usa-collection__heading') + ).toBeInTheDocument(); + }); + + it('should have no accessibility violations', async () => { + const { container } = render( + + + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/ncids/Collection/Collection.tsx b/src/components/ncids/Collection/Collection.tsx new file mode 100644 index 0000000..6576eb4 --- /dev/null +++ b/src/components/ncids/Collection/Collection.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +export interface CollectionItemProps { + /** Item heading content. */ + heading: React.ReactNode; + /** URL for the heading link. If provided, heading is wrapped in an anchor. */ + href?: string; + /** Heading element level. Defaults to 'h3'. */ + headingLevel?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + /** Item description text, rendered in a

. */ + description?: React.ReactNode; + /** Click handler for the heading link. */ + onHeadingClick?: (e: React.MouseEvent) => void; + /** Additional CSS classes on the

  • . */ + className?: string; + /** Extra content rendered below description inside collection__body. */ + children?: React.ReactNode; +} + +export interface CollectionProps { + /** Collection items (should be CollectionItem elements). */ + children: React.ReactNode; + /** Use condensed variant (headers only). */ + condensed?: boolean; + /** Additional CSS classes on the
      . */ + className?: string; +} + +export const CollectionItem: React.FC = ({ + heading, + href, + headingLevel = 'h3', + description, + onHeadingClick, + className, + children, +}) => { + const HeadingTag = headingLevel; + + return ( +
    • +
      + + {href ? ( + + {heading} + + ) : ( + heading + )} + + {description && ( +

      {description}

      + )} + {children} +
      +
    • + ); +}; + +export const Collection: React.FC = ({ + children, + condensed = false, + className, +}) => { + const classes = [ + 'usa-collection', + condensed ? 'usa-collection--condensed' : '', + className || '', + ] + .filter(Boolean) + .join(' '); + + return
        {children}
      ; +}; + +export default Collection; diff --git a/src/components/ncids/Collection/index.ts b/src/components/ncids/Collection/index.ts new file mode 100644 index 0000000..820ba7e --- /dev/null +++ b/src/components/ncids/Collection/index.ts @@ -0,0 +1,2 @@ +export { Collection, CollectionItem, default } from './Collection'; +export type { CollectionProps, CollectionItemProps } from './Collection'; diff --git a/src/components/ncids/index.ts b/src/components/ncids/index.ts index 69ce882..5ec3538 100644 --- a/src/components/ncids/index.ts +++ b/src/components/ncids/index.ts @@ -1,3 +1,5 @@ // NCIDS components barrel export export { Pager } from './Pager'; export type { PagerProps, PageChangeData } from './Pager'; +export { Collection, CollectionItem } from './Collection'; +export type { CollectionProps, CollectionItemProps } from './Collection';