Skip to content

feat(Disclosure): add CollapsedDetails property - #2755

Open
GermanVor wants to merge 2 commits into
mainfrom
DisclosureCollapsedDetails
Open

feat(Disclosure): add CollapsedDetails property#2755
GermanVor wants to merge 2 commits into
mainfrom
DisclosureCollapsedDetails

Conversation

@GermanVor

@GermanVor GermanVor commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary by Sourcery

Introduce a Disclosure.CollapsedDetails subcomponent that shows overlay preview content only while the disclosure is collapsed, with supporting styling, types, stories, tests, and documentation.

New Features:

  • Add Disclosure.CollapsedDetails to render content that is visible only when the disclosure is collapsed and hidden when expanded.
  • Export DisclosureCollapsedDetailsProps and a new DisclosureQa.COLLAPSED_DETAILS identifier for testing and QA integration.

Enhancements:

  • Update Disclosure layout and animations to support an overlaid collapsed-content area without affecting the collapsed height.
  • Add a dedicated Storybook example demonstrating usage of Disclosure.CollapsedDetails.

Documentation:

  • Document the Disclosure.CollapsedDetails component and its props in both English and Russian READMEs, including usage examples.

Tests:

  • Add tests covering collapsed details visibility in collapsed/expanded states, toggle behavior on user interaction, and enforcement of a single CollapsedDetails instance per Disclosure.

@GermanVor
GermanVor requested a review from Raubzeug as a code owner July 16, 2026 15:30
@sourcery-ai

sourcery-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a new Disclosure.CollapsedDetails subcomponent that renders overlay content only while the disclosure is collapsed, wires it into the Disclosure composition/children parsing, and provides styling, tests, docs, and stories for its behavior and API.

File-Level Changes

Change Details Files
Introduce Disclosure.CollapsedDetails subcomponent that shows overlay content only when the disclosure is collapsed and animates it in/out.
  • Create DisclosureCollapsedDetails component using CSSTransition tied to DisclosureContext.expanded state and qa attributes.
  • Expose DisclosureCollapsedDetails in DisclosureComposition, attach it to Disclosure as a static property, and export its props type from the package index.
  • Add new qa constant for collapsed details in Disclosure constants.
src/components/Disclosure/DisclosureCollapsedDetails/DisclosureCollapsedDetails.tsx
src/components/Disclosure/Disclosure.tsx
src/components/Disclosure/constants.ts
src/components/Disclosure/index.ts
Update Disclosure layout and styles to support absolutely positioned collapsed overlay content with matching enter/exit animations.
  • Add SCSS variables for expand/collapse animation durations and reuse for both content and collapsed content animations.
  • Wrap details and collapsed details in a new body container that is relatively positioned.
  • Define styles for collapsed-content element (absolute positioning, visibility toggling, enter/exit animation keyframes usage).
src/components/Disclosure/Disclosure.scss
src/components/Disclosure/Disclosure.tsx
Extend child parsing logic to recognize a single Disclosure.CollapsedDetails instance and enforce that only one is provided.
  • Update prepareChildren to detect DisclosureCollapsedDetails elements using isOfType and separate them from summary/details content.
  • Throw a descriptive error when more than one CollapsedDetails component is found among children.
  • Return collapsedDetails alongside summary and details from prepareChildren and render it conditionally in Disclosure.
src/components/Disclosure/Disclosure.tsx
Add unit tests validating collapsed details visibility, toggle behavior, and single-instance constraint.
  • Test that collapsed details are visible while Disclosure is not expanded and hidden while details are shown.
  • Test that collapsed details hide when Disclosure is expanded and that visibility toggles on user click.
  • Test that rendering more than one CollapsedDetails throws a specific error message.
src/components/Disclosure/__tests__/Disclosure.test.tsx
Document and demonstrate the new CollapsedDetails feature in README and Storybook.
  • Add README sections (EN/RU) describing CollapsedDetails usage, behavior, overlay semantics, and props table.
  • Introduce a new story showcasing CollapsedDetails preview content and expanded details.
  • Include examples in sandbox blocks for both language variants.
src/components/Disclosure/README.md
src/components/Disclosure/README-ru.md
src/components/Disclosure/__stories__/Disclosure.stories.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • Wrapping detailsContent inside the new body div only when collapsedDetailsContent is present changes the DOM structure conditionally; consider always rendering the same wrapper to avoid layout/selector differences based on whether collapsed details are used.
  • When the disclosure is expanded, Disclosure.CollapsedDetails is only hidden via CSS and aria-hidden, but its children may still be focusable and reachable via keyboard; consider preventing focus (e.g., managing tabIndex or pointer-events) when visible is false to align visual and accessibility behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Wrapping `detailsContent` inside the new `body` div only when `collapsedDetailsContent` is present changes the DOM structure conditionally; consider always rendering the same wrapper to avoid layout/selector differences based on whether collapsed details are used.
- When the disclosure is expanded, `Disclosure.CollapsedDetails` is only hidden via CSS and `aria-hidden`, but its children may still be focusable and reachable via keyboard; consider preventing focus (e.g., managing tabIndex or pointer-events) when `visible` is false to align visual and accessibility behavior.

## Individual Comments

### Comment 1
<location path="src/components/Disclosure/DisclosureCollapsedDetails/DisclosureCollapsedDetails.tsx" line_range="30" />
<code_context>
+        <CSSTransition
+            nodeRef={containerRef}
+            in={visible}
+            addEndListener={(done) => containerRef.current?.addEventListener('animationend', done)}
+            classNames={getCSSTransitionClassNames(b)}
+            appear={true}
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid accumulating multiple `animationend` listeners across transitions.

`addEndListener` currently attaches a new `animationend` handler on every transition and never removes the old ones, so the same node can end up with multiple listeners and `done` can fire multiple times.

Consider either:
- Using a one-time listener (e.g. `{ once: true }` if available), or
- Removing the listener via `removeEventListener` inside the callback, or
- Using `timeout` with `onEntered`/`onExited` if the animation event isn’t strictly required.

This keeps one effective listener per transition and avoids repeated registrations on the same node.
</issue_to_address>

### Comment 2
<location path="src/components/Disclosure/__tests__/Disclosure.test.tsx" line_range="207-216" />
<code_context>
         expect(disclosure).toHaveClass('g-disclosure__trigger_arrow_end');
     });

+    test('collapsed details are visible when not expanded', () => {
+        const collapsed = 'Collapsed content';
+        const details = 'Details content';
+        render(
+            <Disclosure expanded={false}>
+                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
+                {details}
+            </Disclosure>,
+        );
+
+        expect(screen.getByText(collapsed)).toHaveClass('g-disclosure__collapsed-content_visible');
+        expect(screen.getByText(details)).not.toHaveClass('g-disclosure__content_visible');
+    });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting `aria-hidden` state for collapsed details to capture the accessibility behavior.

These tests only assert visibility via CSS classes, but `DisclosureCollapsedDetails` also toggles `aria-hidden` based on `expanded`. Please add `aria-hidden` checks for both collapsed and expanded cases so the tests cover the accessibility behavior and guard against regressions impacting screen readers.

Suggested implementation:

```typescript
    test('collapsed details are visible when not expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={false}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        const collapsedElement = screen.getByText(collapsed);
        const detailsElement = screen.getByText(details);

        expect(collapsedElement).toHaveClass('g-disclosure__collapsed-content_visible');
        expect(detailsElement).not.toHaveClass('g-disclosure__content_visible');

        // Accessibility: collapsed details should not be hidden from assistive technologies when not expanded
        expect(collapsedElement).toHaveAttribute('aria-hidden', 'false');
    });

    test('collapsed details are hidden from assistive technologies when expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={true}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        const collapsedElement = screen.getByText(collapsed);

        // Accessibility: collapsed details should be hidden from assistive technologies when expanded
        expect(collapsedElement).toHaveAttribute('aria-hidden', 'true');
    });

```

If `aria-hidden` is applied on a different element (e.g. a wrapper around the collapsed content) rather than directly on the text node, you may need to adjust `collapsedElement` to query that specific element (for example, using `getByTestId` or traversing `collapsedElement.parentElement`) so the expectations match the actual DOM structure.
</issue_to_address>

### Comment 3
<location path="src/components/Disclosure/__tests__/Disclosure.test.tsx" line_range="237-246" />
<code_context>
+        expect(screen.getByText(details)).toHaveClass('g-disclosure__content_visible');
+    });
+
+    test('collapsed details visibility toggles when clicked', async () => {
+        const user = userEvent.setup();
+        const collapsed = 'Collapsed content';
+        render(
+            <Disclosure>
+                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
+            </Disclosure>,
+        );
+        const disclosure = screen.getByRole('button');
+        const component = screen.getByText(collapsed);
+
+        expect(component).toHaveClass('g-disclosure__collapsed-content_visible');
+        await user.click(disclosure);
+        expect(component).not.toHaveClass('g-disclosure__collapsed-content_visible');
+    });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to verify `data-qa` behavior for `Disclosure.CollapsedDetails` (default and custom).

Please add tests that assert the default `data-qa` value (`DisclosureQa.COLLAPSED_DETAILS`) and that a custom `qa` prop overrides it. This will keep `Disclosure.CollapsedDetails` consistent with existing QA-related tests and provide coverage for the new QA hook.

Suggested implementation:

```typescript
        expect(screen.getByText(collapsed)).not.toHaveClass(
            'g-disclosure__collapsed-content_visible',
        );
        expect(screen.getByText(details)).toHaveClass('g-disclosure__content_visible');
    });

    test('collapsed details are visible when not expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={false}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        expect(screen.getByText(collapsed)).toHaveClass('g-disclosure__collapsed-content_visible');
    });

    test('collapsed details have default data-qa', () => {
        const collapsed = 'Collapsed content';
        render(
            <Disclosure>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
            </Disclosure>,
        );

        const component = screen.getByText(collapsed);
        expect(component).toHaveAttribute('data-qa', DisclosureQa.COLLAPSED_DETAILS);
    });

    test('collapsed details allow overriding data-qa via qa prop', () => {
        const collapsed = 'Collapsed content';
        const customQa = 'custom-collapsed-details-qa';
        render(
            <Disclosure>
                <Disclosure.CollapsedDetails qa={customQa}>{collapsed}</Disclosure.CollapsedDetails>
            </Disclosure>,
        );

        const component = screen.getByText(collapsed);
        expect(component).toHaveAttribute('data-qa', customQa);
    });


```

1. At the top of `Disclosure.test.tsx`, import `DisclosureQa` from the same module used by other QA-related tests in this file. For example, if other tests already use QA enums, mirror that import style:
   - `import { DisclosureQa } from '../Disclosure.qa';` (or the correct relative path in your codebase).
2. Ensure `Disclosure.CollapsedDetails` accepts a `qa` prop and passes it through to the rendered DOM element as `data-qa`, defaulting to `DisclosureQa.COLLAPSED_DETAILS` when `qa` is not provided. This behavior should already exist or be added in the `Disclosure.CollapsedDetails` implementation to make these tests pass.
</issue_to_address>

### Comment 4
<location path="src/components/Disclosure/README-ru.md" line_range="211-212" />
<code_context>
+
+Используйте компонент `Disclosure.CollapsedDetails`, чтобы отрендерить контент, который отображается
+только пока `Disclosure` свёрнут (`expanded === false`) и плавно исчезает при раскрытии. Он
+рендерится как абсолютно позиционированный оверлей поверх области с деталями, поэтому не увеличивает
+высоту свёрнутого компонента, и контент не «прыгает» при переключении.
+
+<!--SANDBOX
</code_context>
<issue_to_address>
**nitpick (typo):** Запятая перед «и контент» здесь, вероятно, лишняя для общего подлежащего.

Предлагаю убрать запятую и использовать формулировку: «…поэтому не увеличивает высоту свёрнутого компонента и контент не „прыгает“ при переключении».

```suggestion
рендерится как абсолютно позиционированный оверлей поверх области с деталями, поэтому не увеличивает
высоту свёрнутого компонента и контент не «прыгает» при переключении.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +207 to +216
test('collapsed details are visible when not expanded', () => {
const collapsed = 'Collapsed content';
const details = 'Details content';
render(
<Disclosure expanded={false}>
<Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
{details}
</Disclosure>,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider asserting aria-hidden state for collapsed details to capture the accessibility behavior.

These tests only assert visibility via CSS classes, but DisclosureCollapsedDetails also toggles aria-hidden based on expanded. Please add aria-hidden checks for both collapsed and expanded cases so the tests cover the accessibility behavior and guard against regressions impacting screen readers.

Suggested implementation:

    test('collapsed details are visible when not expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={false}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        const collapsedElement = screen.getByText(collapsed);
        const detailsElement = screen.getByText(details);

        expect(collapsedElement).toHaveClass('g-disclosure__collapsed-content_visible');
        expect(detailsElement).not.toHaveClass('g-disclosure__content_visible');

        // Accessibility: collapsed details should not be hidden from assistive technologies when not expanded
        expect(collapsedElement).toHaveAttribute('aria-hidden', 'false');
    });

    test('collapsed details are hidden from assistive technologies when expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={true}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        const collapsedElement = screen.getByText(collapsed);

        // Accessibility: collapsed details should be hidden from assistive technologies when expanded
        expect(collapsedElement).toHaveAttribute('aria-hidden', 'true');
    });

If aria-hidden is applied on a different element (e.g. a wrapper around the collapsed content) rather than directly on the text node, you may need to adjust collapsedElement to query that specific element (for example, using getByTestId or traversing collapsedElement.parentElement) so the expectations match the actual DOM structure.

Comment on lines +237 to +246
test('collapsed details visibility toggles when clicked', async () => {
const user = userEvent.setup();
const collapsed = 'Collapsed content';
render(
<Disclosure>
<Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
</Disclosure>,
);
const disclosure = screen.getByRole('button');
const component = screen.getByText(collapsed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test to verify data-qa behavior for Disclosure.CollapsedDetails (default and custom).

Please add tests that assert the default data-qa value (DisclosureQa.COLLAPSED_DETAILS) and that a custom qa prop overrides it. This will keep Disclosure.CollapsedDetails consistent with existing QA-related tests and provide coverage for the new QA hook.

Suggested implementation:

        expect(screen.getByText(collapsed)).not.toHaveClass(
            'g-disclosure__collapsed-content_visible',
        );
        expect(screen.getByText(details)).toHaveClass('g-disclosure__content_visible');
    });

    test('collapsed details are visible when not expanded', () => {
        const collapsed = 'Collapsed content';
        const details = 'Details content';
        render(
            <Disclosure expanded={false}>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
                {details}
            </Disclosure>,
        );

        expect(screen.getByText(collapsed)).toHaveClass('g-disclosure__collapsed-content_visible');
    });

    test('collapsed details have default data-qa', () => {
        const collapsed = 'Collapsed content';
        render(
            <Disclosure>
                <Disclosure.CollapsedDetails>{collapsed}</Disclosure.CollapsedDetails>
            </Disclosure>,
        );

        const component = screen.getByText(collapsed);
        expect(component).toHaveAttribute('data-qa', DisclosureQa.COLLAPSED_DETAILS);
    });

    test('collapsed details allow overriding data-qa via qa prop', () => {
        const collapsed = 'Collapsed content';
        const customQa = 'custom-collapsed-details-qa';
        render(
            <Disclosure>
                <Disclosure.CollapsedDetails qa={customQa}>{collapsed}</Disclosure.CollapsedDetails>
            </Disclosure>,
        );

        const component = screen.getByText(collapsed);
        expect(component).toHaveAttribute('data-qa', customQa);
    });
  1. At the top of Disclosure.test.tsx, import DisclosureQa from the same module used by other QA-related tests in this file. For example, if other tests already use QA enums, mirror that import style:
    • import { DisclosureQa } from '../Disclosure.qa'; (or the correct relative path in your codebase).
  2. Ensure Disclosure.CollapsedDetails accepts a qa prop and passes it through to the rendered DOM element as data-qa, defaulting to DisclosureQa.COLLAPSED_DETAILS when qa is not provided. This behavior should already exist or be added in the Disclosure.CollapsedDetails implementation to make these tests pass.

Comment thread src/components/Disclosure/README-ru.md Outdated
@gravity-ui

gravity-ui Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Preview is ready.

@gravity-ui

gravity-ui Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

@GermanVor
GermanVor force-pushed the DisclosureCollapsedDetails branch from 9bd2fb8 to 2fc7fe9 Compare July 16, 2026 15:53
summary = item;
continue;
}
const isDisclosureCollapsedDetails = isDisclosureCollapsedDetailsComponent(item);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we handle an explicit <Disclosure.Details> as a first-class child here as well? In the intended composition:

<Disclosure>
  <Disclosure.CollapsedDetails>Preview</Disclosure.CollapsedDetails>
  <Disclosure.Details>Full view</Disclosure.Details>
</Disclosure>

the explicit Disclosure.Details currently falls through to content and is then wrapped in another DisclosureDetails below. This produces two transition containers and two role="region" elements with the same ariaControls ID; with CollapsedDetails, both content layers also receive the transition classes.

Could we preserve an explicit Disclosure.Details instance (and define the behavior when it is mixed with raw children) and add a regression test using the exact composition above?

// Inside the shared body wrapper the disappearing content is taken out of the
// flow and overlaid on top of the appearing one, so their heights don't briefly
// sum up and make the layout jump. At rest, both stay in the normal flow.
&__body &__content#{$block}_exit_active,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On collapse, __content is the exiting element, so this rule removes the full details from normal flow immediately. If the expanded details are taller than the collapsed preview, __body shrinks to the preview height and the following sibling moves up while the outgoing details are still painted for 100 ms, causing them to overlap.

This is the layout jump/overlap that the new API is intended to avoid. Could we instead keep the two direct children in the same CSS Grid area so the track uses the larger height while both are present, or otherwise preserve/animate the __body height during the transition?

It would also be useful to cover this with an interaction component test that collapses details taller than the preview and checks the position of the following sibling during the transition; the current jsdom tests only assert class changes.

nodeRef={containerRef}
in={visible}
addEndListener={(done) => containerRef.current?.addEventListener('animationend', done)}
classNames={getCSSTransitionClassNames(b)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier Sourcery thread was marked as addressed after this logic moved out of DisclosureCollapsedDetails, but the listener lifecycle is unchanged here. Every transition adds another animationend listener, and with the default keepMounted={true} the same DOM node remains mounted, so completed and cancelled callbacks stay attached and are invoked by later animation events.

Could we register a removable one-shot handler for each transition (ideally filtering event.target === containerRef.current so animated descendants cannot complete it), or use another completion mechanism that provides cleanup?

export interface DisclosureComposition {
Summary: typeof DisclosureSummary;
Details: typeof DisclosureDetails;
CollapsedDetails: typeof DisclosureCollapsedDetails;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we reconsider the public name before merging? CollapsedDetails is somewhat difficult to interpret because “details” normally refers to the panel shown in the expanded state.

Would Disclosure.Preview describe the intended role more clearly?

<Disclosure>
  <Disclosure.Preview>Short view</Disclosure.Preview>
  <Disclosure.Details>Full view</Disclosure.Details>
</Disclosure>

If the intended use case is instead a truncated preview of the same content, that may be better represented by a separate Spoiler component or a collapsedHeight API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants