Skip to content
Open
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
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
"rimraf": "^6.1.2",
"rollup": "^4.54.0",
"rollup-plugin-clear": "^2.0.7",
"shadow-dom-testing-library": "^1.13.1",
"storage-mock": "^2.1.0",
"storybook": "10.1.11",
"stylelint": "^16.26.1",
Expand Down
15 changes: 10 additions & 5 deletions src/collapse/collapse-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React, {useState, useEffect, useRef, useContext, type PropsWithChildren}
import classNames from 'classnames';

import dataTests from '../global/data-tests';
import {getRect} from '../global/dom';
import {toPx} from './utils';
import CollapseContext from './collapse-context';
import {COLLAPSE_CONTENT_TEST_ID, COLLAPSE_CONTENT_CONTAINER_TEST_ID} from './consts';
Expand Down Expand Up @@ -60,13 +59,19 @@ export const CollapseContent: React.FC<PropsWithChildren<Props>> = ({
}

useEffect(() => {
if (contentRef.current) {
const observer = new ResizeObserver(() => {
setContentHeight(getRect(contentRef.current).height);
});
const observer = new ResizeObserver(([entry]) => {
if (entry.target.isConnected) {
setContentHeight(entry.contentRect.height);
}
});

if (contentRef.current) {
observer.observe(contentRef.current);
}

return () => {
observer.disconnect();
};
}, []);

const calculatedDuration = duration + contentHeight * DURATION_FACTOR;
Expand Down
17 changes: 15 additions & 2 deletions src/collapse/collapse.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import LoaderInline from '../loader-inline/loader-inline';
import Collapse from './collapse';
import CollapseContent from './collapse-content';
import CollapseControl from './collapse-control';
import {createShadowRootStyleSheet, ShadowRootWrap} from './shadow-root-test-helpers';

import styles from './collapse.stories.css';

Expand Down Expand Up @@ -84,11 +85,11 @@ export const WithIcon = () => (
<Collapse>
<CollapseControl>
{(collapsed: boolean) =>
(collapsed ? (
collapsed ? (
<Button aria-label='Expand' icon={ChevronDownIcon} className={styles.check} />
) : (
<Button aria-label='Collapse' icon={ChevronUpIcon} className={styles.check} />
))
)
}
</CollapseControl>
<CollapseContent>{text}</CollapseContent>
Expand Down Expand Up @@ -202,3 +203,15 @@ WithControlledCollapseState.storyName = 'With controlled collapse state';
WithControlledCollapseState.parameters = {
screenshots: {actions: [{type: 'click', selector: '[data-test~=trigger]'}]},
};

export const WrappedInShadowRoot = () => {
const styleSheet = createShadowRootStyleSheet(['.container', '.transition', '.summary', '.trigger', '.fade']);

return (
<ShadowRootWrap adoptedStyleSheets={[styleSheet]}>
<Basic />
</ShadowRootWrap>
);
};

WrappedInShadowRoot.storyName = 'Wrapped in shadow root';
107 changes: 106 additions & 1 deletion src/collapse/collapse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import {type PropsWithChildren, useState} from 'react';
import * as React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {afterAll, describe, expect} from 'vitest';
import {screen as shadowScreen} from 'shadow-dom-testing-library';

import {COLLAPSE_CONTENT_CONTAINER_TEST_ID} from './consts';
import {COLLAPSE_CONTENT_CONTAINER_TEST_ID, COLLAPSE_CONTENT_TEST_ID} from './consts';
import {Collapse} from './collapse';
import {CollapseContent} from './collapse-content';
import {CollapseControl} from './collapse-control';
import {ShadowRootWrap} from './shadow-root-test-helpers';

import styles from './collapse.css';

Expand Down Expand Up @@ -157,4 +160,106 @@ describe('<Collapse />', () => {

expect(content).to.contain.text(textMock);
});

type PartialObserverEntry = Partial<{
[K in keyof ResizeObserverEntry]: Partial<ResizeObserverEntry[K]>;
}>;

describe('with ResizeObserver', () => {
let observerCallback: (entries: PartialObserverEntry[]) => void;
const observeMock = vi.fn();
const disconnectMock = vi.fn();
const unobserveMock = vi.fn();

const originalObserver = global.ResizeObserver;

beforeEach(() => {
global.ResizeObserver = vi.fn(
class {
constructor(cb: ResizeObserverCallback) {
observerCallback = cb as typeof observerCallback;
}

observe = observeMock;
disconnect = disconnectMock;
unobserve = unobserveMock;
},
);
});

afterEach(() => {
vi.clearAllMocks();
});

afterAll(() => {
global.ResizeObserver = originalObserver;
});

it('should track height via ResizeObserver and cleanup on unmount', async () => {
const {unmount} = renderComponent();
const button = screen.getByRole('button', {name: 'Show text'});
const contentContainer = screen.getByTestId(COLLAPSE_CONTENT_CONTAINER_TEST_ID);
const contentInner = screen.getByTestId(COLLAPSE_CONTENT_TEST_ID);

expect(observeMock).toHaveBeenCalledWith(contentInner);

// simulate resize observer firing on element mounting
observerCallback([
{
target: contentInner,
contentRect: {height: 150},
},
]);

expect(contentContainer.style.height).to.equal('0px');

await userEvent.click(button);

expect(contentContainer.style.height).to.equal('150px');

await userEvent.click(button);

expect(contentContainer.style.height).to.equal('0px');

unmount();

expect(disconnectMock).to.toHaveBeenCalledTimes(1);
});

it('expect to work from inside shadowroot', async () => {
const {unmount} = render(
<ShadowRootWrap>
<Dummy minHeight={0} disableAnimation={false} controlAsFunc={false} defaultCollapsed collapsed={null} />
</ShadowRootWrap>,
);

const button = shadowScreen.getByShadowRole('button', {name: 'Show text'});
const contentContainer = shadowScreen.getByShadowTestId(COLLAPSE_CONTENT_CONTAINER_TEST_ID);
const contentInner = shadowScreen.getByShadowTestId(COLLAPSE_CONTENT_TEST_ID);

expect(observeMock).toHaveBeenCalledWith(contentInner);

// simulate resize observer firing on element mounting
observerCallback([
{
target: contentInner,
contentRect: {height: 150},
},
]);

expect(contentContainer.style.height).to.equal('0px');

await userEvent.click(button);

expect(contentContainer.style.height).to.equal('150px');

await userEvent.click(button);

expect(contentContainer.style.height).to.equal('0px');

unmount();

expect(disconnectMock).to.toHaveBeenCalledTimes(1);
});
});
});
56 changes: 56 additions & 0 deletions src/collapse/shadow-root-test-helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {type FC, type PropsWithChildren, StrictMode} from 'react';
import {createRoot} from 'react-dom/client';

export const createShadowRootStyleSheet = (ruleTitles: string[], ruleCount?: number) => {
const ruleCountLocal = ruleCount ?? ruleTitles.length;

for (const styleSheet of document.styleSheets) {
if (styleSheet.cssRules.length === ruleCountLocal) {
const matchesArr = ruleTitles.map((rule, i) => Boolean(styleSheet.cssRules.item(i)?.cssText.includes(rule)));

if (!matchesArr.includes(false)) {
const newStyleSheet = new CSSStyleSheet();
for (const cssRule of styleSheet.cssRules) {
newStyleSheet.insertRule(cssRule.cssText);
}
return newStyleSheet;
}
}
}

throw new Error('Was unable to match any stylesheets for Collapse component');
};

export const ShadowRootWrap: FC<PropsWithChildren<{adoptedStyleSheets?: CSSStyleSheet[]}>> = ({
children,
adoptedStyleSheets,
}) => {
const refCb = (shadowRootParent: HTMLDivElement) => {
let shadowRoot;
if (shadowRootParent.shadowRoot) {
shadowRoot = shadowRootParent.shadowRoot;
} else {
shadowRoot = shadowRootParent.attachShadow({
mode: 'open',
});
if (adoptedStyleSheets) {
shadowRoot.adoptedStyleSheets = adoptedStyleSheets;
}
}

const rootElem = document.createElement('div');
shadowRoot.replaceChildren(rootElem);

const rootComponent = createRoot(rootElem);

rootComponent.render(<StrictMode>{children}</StrictMode>);

return () => {
queueMicrotask(() => {
rootComponent.unmount();
});
};
};

return <div ref={refCb} />;
};