diff --git a/.changeset/old-boxes-clap.md b/.changeset/old-boxes-clap.md new file mode 100644 index 00000000000..27abb9698ec --- /dev/null +++ b/.changeset/old-boxes-clap.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +feat(Details): Add summary subcomponent diff --git a/packages/react/src/Details/Details.docs.json b/packages/react/src/Details/Details.docs.json index 51e5275b585..0dff5c8d6da 100644 --- a/packages/react/src/Details/Details.docs.json +++ b/packages/react/src/Details/Details.docs.json @@ -11,5 +11,26 @@ "type": "SystemStyleObject" } ], - "subcomponents": [] + "subcomponents": [ + { + "name": "Details.Summary", + "props": [ + { + "name": "as", + "type": "React.ElementType>", + "defaultValue": "summary", + "required": false, + "description": "HTML element to render summary as." + }, + { + "name": "children", + "type": "React.ReactNode" + }, + { + "name": "sx", + "type": "SystemStyleObject" + } + ] + } + ] } diff --git a/packages/react/src/Details/Details.stories.tsx b/packages/react/src/Details/Details.stories.tsx index cdebdccce97..799fee5ac5e 100644 --- a/packages/react/src/Details/Details.stories.tsx +++ b/packages/react/src/Details/Details.stories.tsx @@ -12,7 +12,7 @@ export const Default: StoryFn = () => { const {getDetailsProps} = useDetails({closeOnOutsideClick: true}) return (
- + See Details This is some content
) diff --git a/packages/react/src/Details/Details.tsx b/packages/react/src/Details/Details.tsx index d90f76a7e2a..927edb6091f 100644 --- a/packages/react/src/Details/Details.tsx +++ b/packages/react/src/Details/Details.tsx @@ -1,4 +1,4 @@ -import React, {type ComponentPropsWithoutRef, type ReactElement} from 'react' +import React, {useEffect, useState, type ComponentPropsWithoutRef, type ReactElement} from 'react' import styled from 'styled-components' import type {SxProp} from '../sx' import sx from '../sx' @@ -6,6 +6,7 @@ import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' import {useFeatureFlag} from '../FeatureFlags' import {clsx} from 'clsx' import classes from './Details.module.css' +import {useMergedRefs} from '../internal/hooks/useMergedRefs' const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' @@ -24,18 +25,76 @@ const StyledDetails = toggleStyledComponent( `, ) -const Details = React.forwardRef( - ({className, children, ...rest}, ref): ReactElement => { +const Root = React.forwardRef( + ({className, children, ...rest}, forwardRef): ReactElement => { const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + const detailsRef = React.useRef(null) + const ref = useMergedRefs(forwardRef, detailsRef) + const [hasSummary, setHasSummary] = useState(false) + + useEffect(() => { + const {current: details} = detailsRef + if (!details) { + return + } + + const updateSummary = () => { + const summary = details.querySelector('summary:not([data-default-summary])') + setHasSummary(!!summary) + } + + // Update summary on mount + updateSummary() + + const observer = new MutationObserver(() => { + updateSummary() + }) + + observer.observe(details, { + childList: true, + subtree: true, + }) + + return () => { + observer.disconnect() + } + }, []) + return ( + {/* Include default summary if summary is not provided */} + {!hasSummary && {'See Details'}} {children} ) }, ) -Details.displayName = 'Details' +Root.displayName = 'Details' + +export type SummaryProps = { + /** + * HTML element to render summary as. + */ + as?: As + children?: React.ReactNode +} & React.ComponentPropsWithoutRef + +function Summary({as, children, ...props}: SummaryProps) { + const Component = as ?? 'summary' + return ( + + {children} + + ) +} +Summary.displayName = 'Summary' + +export {Summary} + +const Details = Object.assign(Root, { + Summary, +}) export type DetailsProps = ComponentPropsWithoutRef<'details'> & SxProp export default Details diff --git a/packages/react/src/Details/__tests__/Details.test.tsx b/packages/react/src/Details/__tests__/Details.test.tsx index bac07624624..28be85e0945 100644 --- a/packages/react/src/Details/__tests__/Details.test.tsx +++ b/packages/react/src/Details/__tests__/Details.test.tsx @@ -1,6 +1,6 @@ -import {render} from '@testing-library/react' +import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import React from 'react' +import React, {act} from 'react' import {Details, useDetails, Box, Button} from '../..' import type {ButtonProps} from '../../Button' import {behavesAsComponent, checkExports} from '../../utils/testing' @@ -14,29 +14,36 @@ describe('Details', () => { }) it('should have no axe violations', async () => { - const {container} = render(
) - const results = await axe.run(container) + const {container} = render( +
+ SummaryContent +
, + ) + let results + await act(async () => { + results = await axe.run(container) + }) expect(results).toHaveNoViolations() }) - it('Toggles when you click outside', () => { + it('Toggles when you click outside', async () => { const Component = () => { const {getDetailsProps} = useDetails({closeOnOutsideClick: true}) return (
- hi + hi
) } - const {getByTestId} = render() + const {findByTestId} = render() document.body.click() - expect(getByTestId('details')).not.toHaveAttribute('open') + expect(await findByTestId('details')).not.toHaveAttribute('open') }) - it('Accurately passes down open state', () => { + it('Accurately passes down open state', async () => { const Component = () => { const {getDetailsProps, open} = useDetails({closeOnOutsideClick: true}) return ( @@ -46,12 +53,12 @@ describe('Details', () => { ) } - const {getByTestId} = render() + const {findByTestId} = render() document.body.click() - expect(getByTestId('summary')).toHaveTextContent('Closed') - expect(getByTestId('details')).not.toHaveAttribute('open') + expect(await findByTestId('summary')).toHaveTextContent('Closed') + expect(await findByTestId('details')).not.toHaveAttribute('open') }) it('Can manipulate state with setOpen', async () => { @@ -95,4 +102,53 @@ describe('Details', () => { expect(getByTestId('summary')).toHaveTextContent('Open') }) + + it('Adds default summary if no summary supplied', async () => { + const {getByText} = render(
content
) + + expect(getByText('See Details')).toBeInTheDocument() + expect(getByText('See Details').tagName).toBe('SUMMARY') + }) + + it('Does not add default summary if summary supplied', async () => { + const {findByTestId, findByText} = render( +
+ summary + content +
, + ) + + await expect(findByText('See Details')).rejects.toThrow() + expect(await findByTestId('summary')).toBeInTheDocument() + expect((await findByTestId('summary')).tagName).toBe('SUMMARY') + }) + + it('Does not add default summary if supplied as different element', async () => { + const {findByTestId, findByText} = render( +
+ + custom summary + + content +
, + ) + + await expect(findByText('See Details')).rejects.toThrow() + expect(await findByTestId('summary')).toBeInTheDocument() + expect((await findByTestId('summary')).tagName).toBe('SUMMARY') + }) + + describe('Details.Summary', () => { + behavesAsComponent({Component: Details.Summary, options: {skipSx: true}}) + + it('should support a custom `className` on the container element', () => { + render(test summary) + expect(screen.getByText('test summary')).toHaveClass('custom-class') + }) + + it('should pass extra props onto the container element', () => { + render(test summary) + expect(screen.getByText('test summary')).toHaveAttribute('data-testid', 'test') + }) + }) })