iTwin / iTwinUI

A design system for building beautiful and well-working web interfaces.
https://itwin.github.io/iTwinUI/
MIT License
92 stars 35 forks source link

fix `portalContainer` inheritance across different windows #2011

Closed mayank99 closed 3 days ago

mayank99 commented 2 weeks ago

Changes

Follow-up to #2009. This attempts to reset portalContainer when it is in a different (popout) window than the ThemeProvider itself.

This is how it should work:

Implementation notes

After some iteration, I ultimately decided to add jotai, which is something we had already planned on adding ever since v3 was released. I created a thin wrapper over useAtom called useScopedAtom that falls back to reading the value from the nearest context if it hasn't been set in the current context. This unlocks the main inheritance functionality in this PR, while avoiding conflicts with global/application state. This wrapper is completely abstracted away in ScopeProvider, so the most interesting changes are in other files:

Testing

Added an e2e test, and moved some existing unit tests to e2e for more reliability.

Manually tested in vite-playground using this code:

App.tsx ```tsx import { useState } from 'react'; import { Button, Table, ThemeProvider, tableFilters, } from '@itwin/itwinui-react'; import type { Column } from '@itwin/itwinui-react/react-table'; import { createPortal } from 'react-dom'; export default function App() { const [popoutDocumentBody, setPopoutDocumentBody] = useState(null); const showPopOutWindow = () => { const childWindow = window.open( '', '_blank', 'width=1280,height=720,menubar=no,resizable=yes,scrollbars=no,status=no,location=no', ); setTimeout(() => { if (!childWindow) { console.log('childWindow is null??'); return; } childWindow.document.title = 'Popout window!'; childWindow.document.write(htmlShell); copyStyles(childWindow.document); setPopoutDocumentBody(childWindow.document.body); }); }; return (
{popoutDocumentBody && createPortal( , popoutDocumentBody, )} ); } // ---------------------------------------------------------------------------- /** * Copies the source CSS into the destination * @param targetDoc - target document * @param sourceDoc - source document * @internal */ export function copyStyles( targetDoc: Document, sourceDoc: Document = document, ) { const stylesheets = Array.from([ ...sourceDoc.styleSheets, ...sourceDoc.adoptedStyleSheets, ]); stylesheets.forEach((stylesheet) => { const css = stylesheet; if (stylesheet.href) { const newStyleElement = targetDoc.createElement('link'); newStyleElement.rel = 'stylesheet'; newStyleElement.href = stylesheet.href; targetDoc.head.appendChild(newStyleElement); } else { if (css && css.cssRules && css.cssRules.length > 0) { const newStyleElement = targetDoc.createElement('style'); Array.from(css.cssRules).forEach((rule) => { newStyleElement.appendChild(targetDoc.createTextNode(rule.cssText)); }); targetDoc.head.appendChild(newStyleElement); } } }); // copy sprites const svgSymbolParent = sourceDoc.getElementById('__SVG_SPRITE_NODE__'); if (svgSymbolParent) { targetDoc.body.appendChild(svgSymbolParent.cloneNode(true)); } } // ---------------------------------------------------------------------------- const htmlShell = ``; // ---------------------------------------------------------------------------- const data = [ { product: 'Product 1', quantity: 500, rating: '4/5', }, { product: 'Product 2', quantity: 1200, rating: '1/5', }, { product: 'Product 3', quantity: 1500, rating: '3/5', }, { product: 'Product 4', quantity: 50, rating: '4/5', }, { product: 'Product 5', quantity: 700, rating: '5/5', }, { product: 'Product 6', quantity: 30, rating: '5/5', }, { product: 'Product 7', quantity: 130, rating: '1/5', }, { product: 'Product 8', quantity: 500, rating: '4/5', }, ]; // ---------------------------------------------------------------------------- const columns = [ { id: 'product', Header: 'Product', accessor: 'product', minWidth: 400, Filter: tableFilters.TextFilter(), fieldType: 'text', }, { id: 'quantity', Header: 'Quantity', accessor: 'quantity', width: 400, Filter: tableFilters.TextFilter(), fieldType: 'text', }, { id: 'rating', Header: 'Rating', accessor: 'rating', width: 400, Filter: tableFilters.TextFilter(), fieldType: 'text', }, ] satisfies Column<(typeof data)[0]>[]; ```

The most important difference between this and the playground for the previous PR (#2009) is this:

 {popoutDocumentBody &&
   createPortal(
-    <ThemeProvider portalContainer={popoutDocumentBody}>
+    <ThemeProvider>
       …
     </ThemeProvider>,
     popoutDocumentBody,
   )
 }

(The user doesn't need to specify portalContainer anymore because this PR makes it so the correct window is used automatically)

Docs

Added changesets.