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
71 changes: 71 additions & 0 deletions apps/storybook/stories/select.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { Dialog, Select, Label as LabelPrimitive } from 'radix-ui';
import { foodGroups } from '@repo/test-data/foods';
import styles from './select.stories.module.css';
Expand Down Expand Up @@ -923,6 +924,76 @@ export const Cypress = () => {
);
};

/**
* Renders children inside a shadow root, mirroring all styles from the
* document head so that CSS module classes work inside the shadow DOM.
* Passes the shadow container element to children via a render prop so it
* can be forwarded to Select.Portal (or similar) as the portal target.
*/
function ShadowHost({ children }: { children: (container: HTMLElement) => React.ReactNode }) {
const hostRef = React.useRef<HTMLDivElement>(null);
const [container, setContainer] = React.useState<HTMLElement | null>(null);

React.useLayoutEffect(() => {
if (!hostRef.current || hostRef.current.shadowRoot) return;
const shadow = hostRef.current.attachShadow({ mode: 'open' });

// CSS modules are injected into document.head by style-loader; shadow DOM
// does not inherit those styles, so we clone them in.
for (const node of document.head.querySelectorAll('style, link[rel="stylesheet"]')) {
shadow.appendChild(node.cloneNode(true));
}

const div = document.createElement('div');
shadow.appendChild(div);
setContainer(div);
}, []);

return (
<div ref={hostRef} className="shadow-host">
{container !== null ? createPortal(children(container), container) : null}
</div>
);
}

export const CypressShadowDom = () => {
const [value, setValue] = React.useState<string | undefined>('');

return (
<ShadowHost>
{(shadowContainer) => (
<div style={{ padding: 50 }}>
<Select.Root value={value} onValueChange={setValue}>
<Select.Trigger className={styles.trigger} aria-label="pick a food">
<Select.Value placeholder="Pick a food" />
<Select.Icon />
</Select.Trigger>
<Select.Portal container={shadowContainer}>
<Select.Content position="popper" side="bottom" className={styles.content}>
{/* Constrain height so the viewport is scrollable, making it
easier to test the shadow DOM pointer up bug. */}
<Select.Viewport className={styles.viewport} style={{ maxHeight: '100px' }}>
{foodGroups.map((foodGroup) =>
foodGroup.foods.map((food) => (
<Select.Item key={food.value} className={styles.item} value={food.value}>
<Select.ItemText>{food.label}</Select.ItemText>
<Select.ItemIndicator className={styles.indicator}>
<TickIcon />
</Select.ItemIndicator>
</Select.Item>
)),
)}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
{value && <p>food: {value}</p>}
</div>
)}
</ShadowHost>
);
};

type PaddedElement = 'content' | 'viewport';

interface ChromaticSelectProps extends React.ComponentProps<typeof Select.Trigger> {
Expand Down
34 changes: 34 additions & 0 deletions cypress/e2e/Select.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,37 @@ describe('Select', () => {
});
});
});

describe('Select (shadow DOM)', () => {
beforeEach(() => {
cy.visitStory('select--cypress-shadow-dom');
});

describe('given a select with a shadow DOM portal', () => {
it('should remain open after touch-scrolling and allow item selection', () => {
// open select with a touch event
cy.get('.shadow-host')
.shadow()
.findByLabelText(/pick a food/i)
.realTouch();

// trigger a touch scroll, triggering the pointer move event and ensuring
// we do not preventDefault on the upcoming pointer up event
cy.get('.shadow-host').shadow().find('[data-radix-select-viewport]').realSwipe('toTop', {
length: 30,
});

// assert the select content is still open after swiping
cy.get('.shadow-host').shadow().findByRole('listbox').should('exist');

// select an item after scrolling
cy.get('.shadow-host')
.shadow()
.findByRole('option', { name: /Grapes/i })
.realTouch();

// assert the select value has been updated
cy.get('.shadow-host').shadow().findByText(/food:/).should('include.text', 'grapes');
});
});
});
3 changes: 2 additions & 1 deletion packages/react/select/src/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,8 @@ const SelectContentImpl = React.forwardRef<SelectContentImplElement, SelectConte
event.preventDefault();
} else {
// otherwise, if the event was outside the content, close.
if (!content.contains(event.target as HTMLElement)) {
// Use composedPath so this works across shadow DOM boundaries.
if (!event.composedPath().includes(content)) {
onOpenChange(false);
}
}
Expand Down