diff --git a/knip.config.ts b/knip.config.ts index 4beec37cc224d2..4f7f9fe864b73f 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -27,6 +27,8 @@ const productionEntryPoints = [ 'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}', // TODO: Remove when wired into the connect repository modal 'static/app/components/connectRepository/**/*.{ts,tsx}', + // TODO: Remove when consumed in production (#117849 wires it into alert frequency) + 'static/app/views/onboarding/components/scmCollapsibleSection.tsx', ]; const testingEntryPoints = [ diff --git a/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx b/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx new file mode 100644 index 00000000000000..3dd72301defd7a --- /dev/null +++ b/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx @@ -0,0 +1,55 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {ScmCollapsibleSection} from './scmCollapsibleSection'; + +describe('ScmCollapsibleSection', () => { + it('renders the title and content expanded by default', () => { + render( + +
Body content
+
+ ); + + expect(screen.getByRole('button', {name: 'Section title'})).toBeInTheDocument(); + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('starts collapsed when defaultExpanded is false', () => { + render( + +
Body content
+
+ ); + + expect(screen.queryByText('Body content')).not.toBeInTheDocument(); + }); + + it('toggles the content when the title is clicked', async () => { + render( + +
Body content
+
+ ); + + const toggle = screen.getByRole('button', {name: 'Section title'}); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + await userEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Body content')).not.toBeInTheDocument(); + + await userEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('renders trailing content in the header', () => { + render( + Trailing}> +
Body content
+
+ ); + + expect(screen.getByText('Trailing')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/onboarding/components/scmCollapsibleSection.tsx b/static/app/views/onboarding/components/scmCollapsibleSection.tsx new file mode 100644 index 00000000000000..bd2839463000c7 --- /dev/null +++ b/static/app/views/onboarding/components/scmCollapsibleSection.tsx @@ -0,0 +1,101 @@ +import {useId, useState} from 'react'; +import styled from '@emotion/styled'; +import {AnimatePresence, motion} from 'framer-motion'; + +import {Button} from '@sentry/scraps/button'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {IconChevron} from 'sentry/icons'; + +interface ScmCollapsibleSectionProps { + children: React.ReactNode; + title: React.ReactNode; + /** + * Whether the section starts expanded. Defaults to true. + */ + defaultExpanded?: boolean; + /** + * Rendered at the far right of the title row (e.g. a helper label). Stays in + * the header whether or not the section is expanded. + */ + trailing?: React.ReactNode; +} + +/** + * A collapsible section for the SCM project-creation flow: a chevron and the + * title share one transparent toggle button (mirroring the core Disclosure + * look) with an optional trailing slot pinned right, and the body animates its + * own height so sibling cards in a framer-motion `layout="position"` group + * follow via normal document flow. `initial={false}` keeps it from animating on + * mount, so it renders in its `defaultExpanded` state. + * + * This is a local variant of the core Disclosure rather than a consumer of it: + * Disclosure.Content hides with `display: none`, which can't tween and won't + * reflow sibling cards, and Disclosure.Title's full-width stretched button + * can't express a content-hugging toggle without forking the shared component. + */ +export function ScmCollapsibleSection({ + title, + trailing, + defaultExpanded = true, + children, +}: ScmCollapsibleSectionProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const contentId = useId(); + + return ( + + + } + aria-expanded={expanded} + // Only reference the content while it is in the DOM: the body is + // conditionally rendered, so a static aria-controls would point at a + // missing IDREF when collapsed. + aria-controls={expanded ? contentId : undefined} + onClick={() => setExpanded(value => !value)} + > + + {title} + + + {trailing} + + + {expanded && ( + + {children} + + )} + + + ); +} + +// Mirrors core Disclosure's StretchedButton: a transparent toggle holding the +// chevron + title that hugs its content, with the left padding pulled in so the +// chevron sits near-flush with the section edge. +const ToggleButton = styled(Button)` + padding-left: ${p => p.theme.space.xs}; +`; + +// Indents the body so its left edge lines up with the title copy inside +// ToggleButton: button padding-left (xs) + chevron width (md button -> sm icon, +// 14px) + the button's icon gap (md). Matches core Disclosure's 26px inset. +// padding-top lives here (not as a Stack gap) so the spacing collapses with the +// height animation instead of leaving a gap behind the title. +const Content = styled(Stack)` + padding-top: ${p => p.theme.space.md}; + padding-left: calc(${p => p.theme.space.xs} + 14px + ${p => p.theme.space.md}); +`;