diff --git a/apps/storybook/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx index 7e9cd1007..7f0fb66c7 100644 --- a/apps/storybook/stories/select.stories.tsx +++ b/apps/storybook/stories/select.stories.tsx @@ -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'; @@ -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(null); + const [container, setContainer] = React.useState(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 ( +
+ {container !== null ? createPortal(children(container), container) : null} +
+ ); +} + +export const CypressShadowDom = () => { + const [value, setValue] = React.useState(''); + + return ( + + {(shadowContainer) => ( +
+ + + + + + + + {/* Constrain height so the viewport is scrollable, making it + easier to test the shadow DOM pointer up bug. */} + + {foodGroups.map((foodGroup) => + foodGroup.foods.map((food) => ( + + {food.label} + + + + + )), + )} + + + + + {value &&

food: {value}

} +
+ )} +
+ ); +}; + type PaddedElement = 'content' | 'viewport'; interface ChromaticSelectProps extends React.ComponentProps { diff --git a/cypress/e2e/Select.cy.ts b/cypress/e2e/Select.cy.ts index af817028d..ad68fdbf4 100644 --- a/cypress/e2e/Select.cy.ts +++ b/cypress/e2e/Select.cy.ts @@ -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'); + }); + }); +}); diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 01f51923e..6dd958086 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -630,7 +630,8 @@ const SelectContentImpl = React.forwardRef