Skip to content

fix(Accordion): sync defaultExpanded with shared accordion state - #2756

Open
Devil1716 wants to merge 1 commit into
gravity-ui:mainfrom
Devil1716:fix/2718-accordion-default-expanded
Open

fix(Accordion): sync defaultExpanded with shared accordion state#2756
Devil1716 wants to merge 1 commit into
gravity-ui:mainfrom
Devil1716:fix/2718-accordion-default-expanded

Conversation

@Devil1716

@Devil1716 Devil1716 commented Jul 18, 2026

Copy link
Copy Markdown

Summary

  • Seed defaultExpanded={true} into shared accordion items so Disclosure stays controlled by accordion state
  • Always pass expanded={isExpanded} (stop leaving Disclosure uncontrolled when defaultExpanded is true)
  • Add a regression test: a defaultExpanded item closes when another item opens, and can reopen

Closes #2718

Test plan

  • Accordion unit tests for defaultExpanded (existing + new exclusive-open test)

Summary by Sourcery

Synchronize AccordionItem defaultExpanded behavior with shared accordion state so items remain controlled and exclusive, and add coverage for the default-expanded exclusive-open scenario.

Bug Fixes:

  • Ensure Accordion items with defaultExpanded=true stay in sync with shared accordion state and close when other items open.

Tests:

  • Add a regression test verifying a defaultExpanded item closes when another item opens and can be reopened.

Seed defaultExpanded=true into context items and always drive Disclosure from isExpanded so items can toggle and exclusive open/close works.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sourcery-ai

sourcery-ai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Ensures Accordion items using defaultExpanded=true stay synchronized with the shared accordion state by always controlling Disclosure via accordion state, seeding default-expanded items into the shared items collection, and adding a regression test for exclusive-open behavior.

File-Level Changes

Change Details Files
Always control Disclosure expansion from accordion state and stop relying on defaultExpanded at the Disclosure level.
  • Simplified disclosureExpanded to always use isExpanded.
  • Removed passing defaultExpanded to Disclosure, ensuring it is never left uncontrolled.
src/components/Accordion/AccordionItem/AccordionItem.tsx
Seed defaultExpanded items into shared accordion state and derive expansion from shared items instead of a local default flag.
  • Introduced itemValue and seededDefaultExpanded ref to track item identity and seeding status.
  • Added a useLayoutEffect that, when defaultExpanded=true on an uncontrolled item, inserts the item into the accordion items state if it is not already open.
  • Reworked isExpanded computation to rely on items and itemValue and to honor defaultExpanded only on first paint before seeding.
  • Updated handleUpdate to always update shared items for uncontrolled items using itemValue instead of recomputing value/id.
src/components/Accordion/AccordionItem/AccordionItem.tsx
Add regression coverage for defaultExpanded behavior with exclusive open/close semantics.
  • Added a test case asserting that a defaultExpanded item closes when another item opens and can be reopened, validating shared state synchronization.
src/components/Accordion/__tests__/Accordion.test.tsx

Assessment against linked issues

Issue Objective Addressed Explanation
#2718 Fix Accordion.Item so that when defaultExpanded is set (including defaultExpanded=false), the item can be toggled between collapsed and expanded in sync with the shared accordion state.

Possibly linked issues


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 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/components/Accordion/__tests__/Accordion.test.tsx" line_range="294-303" />
<code_context>
+    test('AccordionItem with defaultExpanded=true closes when another item opens', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a complementary test for non-exclusive (multiple-open) accordions with defaultExpanded items

Consider adding a sibling test for the non-exclusive Accordion case (e.g. when `multiple` is enabled). In that scenario, a `defaultExpanded` item should remain open when another item opens and stay consistent with the shared state. This will help catch regressions where the seeding logic incorrectly forces items closed for multiple-open accordions.

Suggested implementation:

```typescript
        expect(content2.className).toContain('g-disclosure__content_visible');
    });

    test('AccordionItem with defaultExpanded=true remains open in multiple mode when another item opens', async () => {
        const user = userEvent.setup();

        render(
            <Accordion multiple>
                <Accordion.Item qa="item-1" summary="Item 1" defaultExpanded>
                    Content 1
                </Accordion.Item>
                <Accordion.Item qa="item-2" summary="Item 2">
                    Content 2
                </Accordion.Item>
            </Accordion>,
        );

        const item1Summary = screen.getByText('Item 1');
        const item2Summary = screen.getByText('Item 2');

        const content1 = screen.getByText('Content 1');
        const content2 = screen.getByText('Content 2');

        // defaultExpanded item should start open
        expect(content1.className).toContain('g-disclosure__content_visible');
        expect(content2.className).not.toContain('g-disclosure__content_visible');

        // opening another item in multiple mode should NOT close the defaultExpanded item
        await user.click(item2Summary);

        expect(content1.className).toContain('g-disclosure__content_visible');
        expect(content2.className).toContain('g-disclosure__content_visible');
    });

    test('AccordionItem with defaultExpanded=true closes when another item opens', async () => {
        const user = userEvent.setup();

        render(
            <Accordion>
                <Accordion.Item qa="item-1" summary="Item 1" defaultExpanded>
                    Content 1
                </Accordion.Item>
                <Accordion.Item qa="item-2" summary="Item 2">
                    Content 2
                </Accordion.Item>

```

This change assumes that `screen` from `@testing-library/react` is already imported in this test file, and that `"g-disclosure__content_visible"` is the canonical class used elsewhere in the tests to assert visibility.  
If the existing tests use different query helpers (e.g. `render` return values instead of `screen`) or different visibility checks, mirror those conventions in the new test (e.g. replace `screen.getByText` with the existing pattern for locating summaries and content nodes).
</issue_to_address>

### Comment 2
<location path="src/components/Accordion/AccordionItem/AccordionItem.tsx" line_range="121" />
<code_context>
+        }
+    }, [isControlledItem, defaultExpanded, itemValue, items, updateItems]);

     const isExpanded = React.useMemo(() => {
         if (isControlledItem) {
             return expanded;
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting a shared helper to determine whether an accordion item is open and reuse it in both the seeding effect and `isExpanded` to simplify the control flow.

You can keep the new behavior but reduce the cognitive load by centralizing the “is this item open?” logic and reusing it across the effect and `isExpanded`. That removes the repeated array/scalar branching and makes the seeding flow easier to follow.

For example:

```ts
function isItemOpen(
    items: string | string[] | undefined,
    itemValue: string,
): boolean {
    if (items == null) return false;
    return Array.isArray(items) ? items.includes(itemValue) : items === itemValue;
}

function useAccordionItemState({
    expanded,
    defaultExpanded,
    value,
    onUpdate,
}: {
    expanded?: boolean;
    defaultExpanded?: boolean;
    value?: string;
    onUpdate?: (next: boolean) => void;
}) {
    const id = useUniqId();
    const {items, updateItems} = useAccordion();
    const isControlledItem = expanded !== undefined;
    const itemValue = value ?? id;
    const seededDefaultExpanded = React.useRef(false);

    React.useLayoutEffect(() => {
        if (seededDefaultExpanded.current || isControlledItem || defaultExpanded !== true) {
            return;
        }
        seededDefaultExpanded.current = true;

        if (!isItemOpen(items, itemValue)) {
            updateItems(itemValue);
        }
    }, [isControlledItem, defaultExpanded, itemValue, items, updateItems]);

    const isExpanded = React.useMemo(() => {
        if (isControlledItem) {
            return expanded;
        }

        const openInContext = isItemOpen(items, itemValue);
        if (openInContext) {
            return true;
        }

        // Honor defaultExpanded on the first paint before the seed effect runs.
        if (defaultExpanded === true && !seededDefaultExpanded.current) {
            return true;
        }

        return false;
    }, [isControlledItem, expanded, items, itemValue, defaultExpanded]);

    const handleUpdate = React.useCallback(
        (next: boolean) => {
            onUpdate?.(next);
            if (!isControlledItem) {
                updateItems(itemValue);
            }
        },
        [onUpdate, isControlledItem, updateItems, itemValue],
    );

    return {id, isExpanded, handleUpdate};
}
```

This keeps:

- The one-time seeding of `defaultExpanded` into the shared accordion state.
- The first-paint `defaultExpanded` behavior.
- The controlled vs uncontrolled distinction.

But it:

- Removes duplicated `Array.isArray(items)` / scalar checks.
- Makes the seeding effect and `isExpanded` both clearly rely on the same `isItemOpen` definition, reducing branching and mental overhead.
</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 +294 to +303
test('AccordionItem with defaultExpanded=true closes when another item opens', async () => {
const user = userEvent.setup();

render(
<Accordion>
<Accordion.Item qa="item-1" summary="Item 1" defaultExpanded>
Content 1
</Accordion.Item>
<Accordion.Item qa="item-2" summary="Item 2">
Content 2

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 complementary test for non-exclusive (multiple-open) accordions with defaultExpanded items

Consider adding a sibling test for the non-exclusive Accordion case (e.g. when multiple is enabled). In that scenario, a defaultExpanded item should remain open when another item opens and stay consistent with the shared state. This will help catch regressions where the seeding logic incorrectly forces items closed for multiple-open accordions.

Suggested implementation:

        expect(content2.className).toContain('g-disclosure__content_visible');
    });

    test('AccordionItem with defaultExpanded=true remains open in multiple mode when another item opens', async () => {
        const user = userEvent.setup();

        render(
            <Accordion multiple>
                <Accordion.Item qa="item-1" summary="Item 1" defaultExpanded>
                    Content 1
                </Accordion.Item>
                <Accordion.Item qa="item-2" summary="Item 2">
                    Content 2
                </Accordion.Item>
            </Accordion>,
        );

        const item1Summary = screen.getByText('Item 1');
        const item2Summary = screen.getByText('Item 2');

        const content1 = screen.getByText('Content 1');
        const content2 = screen.getByText('Content 2');

        // defaultExpanded item should start open
        expect(content1.className).toContain('g-disclosure__content_visible');
        expect(content2.className).not.toContain('g-disclosure__content_visible');

        // opening another item in multiple mode should NOT close the defaultExpanded item
        await user.click(item2Summary);

        expect(content1.className).toContain('g-disclosure__content_visible');
        expect(content2.className).toContain('g-disclosure__content_visible');
    });

    test('AccordionItem with defaultExpanded=true closes when another item opens', async () => {
        const user = userEvent.setup();

        render(
            <Accordion>
                <Accordion.Item qa="item-1" summary="Item 1" defaultExpanded>
                    Content 1
                </Accordion.Item>
                <Accordion.Item qa="item-2" summary="Item 2">
                    Content 2
                </Accordion.Item>

This change assumes that screen from @testing-library/react is already imported in this test file, and that "g-disclosure__content_visible" is the canonical class used elsewhere in the tests to assert visibility.
If the existing tests use different query helpers (e.g. render return values instead of screen) or different visibility checks, mirror those conventions in the new test (e.g. replace screen.getByText with the existing pattern for locating summaries and content nodes).

}
}, [isControlledItem, defaultExpanded, itemValue, items, updateItems]);

const isExpanded = React.useMemo(() => {

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.

issue (complexity): Consider extracting a shared helper to determine whether an accordion item is open and reuse it in both the seeding effect and isExpanded to simplify the control flow.

You can keep the new behavior but reduce the cognitive load by centralizing the “is this item open?” logic and reusing it across the effect and isExpanded. That removes the repeated array/scalar branching and makes the seeding flow easier to follow.

For example:

function isItemOpen(
    items: string | string[] | undefined,
    itemValue: string,
): boolean {
    if (items == null) return false;
    return Array.isArray(items) ? items.includes(itemValue) : items === itemValue;
}

function useAccordionItemState({
    expanded,
    defaultExpanded,
    value,
    onUpdate,
}: {
    expanded?: boolean;
    defaultExpanded?: boolean;
    value?: string;
    onUpdate?: (next: boolean) => void;
}) {
    const id = useUniqId();
    const {items, updateItems} = useAccordion();
    const isControlledItem = expanded !== undefined;
    const itemValue = value ?? id;
    const seededDefaultExpanded = React.useRef(false);

    React.useLayoutEffect(() => {
        if (seededDefaultExpanded.current || isControlledItem || defaultExpanded !== true) {
            return;
        }
        seededDefaultExpanded.current = true;

        if (!isItemOpen(items, itemValue)) {
            updateItems(itemValue);
        }
    }, [isControlledItem, defaultExpanded, itemValue, items, updateItems]);

    const isExpanded = React.useMemo(() => {
        if (isControlledItem) {
            return expanded;
        }

        const openInContext = isItemOpen(items, itemValue);
        if (openInContext) {
            return true;
        }

        // Honor defaultExpanded on the first paint before the seed effect runs.
        if (defaultExpanded === true && !seededDefaultExpanded.current) {
            return true;
        }

        return false;
    }, [isControlledItem, expanded, items, itemValue, defaultExpanded]);

    const handleUpdate = React.useCallback(
        (next: boolean) => {
            onUpdate?.(next);
            if (!isControlledItem) {
                updateItems(itemValue);
            }
        },
        [onUpdate, isControlledItem, updateItems, itemValue],
    );

    return {id, isExpanded, handleUpdate};
}

This keeps:

  • The one-time seeding of defaultExpanded into the shared accordion state.
  • The first-paint defaultExpanded behavior.
  • The controlled vs uncontrolled distinction.

But it:

  • Removes duplicated Array.isArray(items) / scalar checks.
  • Makes the seeding effect and isExpanded both clearly rely on the same isItemOpen definition, reducing branching and mental overhead.

@gravity-ui

gravity-ui Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Preview is ready.

@gravity-ui

gravity-ui Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

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.

Accordion component defaultExpanded bug

1 participant