Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ScmCollapsibleSection title="Section title">
<div>Body content</div>
</ScmCollapsibleSection>
);

expect(screen.getByRole('button', {name: 'Section title'})).toBeInTheDocument();
expect(screen.getByText('Body content')).toBeInTheDocument();
});

it('starts collapsed when defaultExpanded is false', () => {
render(
<ScmCollapsibleSection title="Section title" defaultExpanded={false}>
<div>Body content</div>
</ScmCollapsibleSection>
);

expect(screen.queryByText('Body content')).not.toBeInTheDocument();
});

it('toggles the content when the title is clicked', async () => {
render(
<ScmCollapsibleSection title="Section title">
<div>Body content</div>
</ScmCollapsibleSection>
);

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(
<ScmCollapsibleSection title="Section title" trailing={<span>Trailing</span>}>
<div>Body content</div>
</ScmCollapsibleSection>
);

expect(screen.getByText('Trailing')).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions static/app/views/onboarding/components/scmCollapsibleSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack gap="0" width="100%">
<Flex justify="between" align="center" width="100%">
<ToggleButton
variant="transparent"
size="md"
icon={<IconChevron direction={expanded ? 'down' : 'right'} />}
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)}
>
<Text as="span" bold>
{title}
</Text>
</ToggleButton>
{trailing}
</Flex>
<AnimatePresence initial={false}>
{expanded && (
Comment thread
sentry[bot] marked this conversation as resolved.
<motion.div
id={contentId}
key="content"
initial={{height: 0, opacity: 0}}
animate={{height: 'auto', opacity: 1}}
exit={{height: 0, opacity: 0}}
transition={{duration: 0.2, ease: 'easeOut'}}
style={{overflow: 'hidden', width: '100%'}}
>
<Content width="100%">{children}</Content>
</motion.div>
)}
</AnimatePresence>
</Stack>
);
}

// 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});
`;
Loading