From 8c7ca2dca5a4a52cf9714b25aa8c8d85d4394f39 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Wed, 24 Apr 2024 16:43:48 +0200 Subject: [PATCH] feat(Portal): take in consideration parent theme (#1506) --- src/components/Modal/Modal.tsx | 58 ++++++------ src/components/Popup/Popup.tsx | 50 +++++----- src/components/Portal/Portal.scss | 9 ++ src/components/Portal/Portal.tsx | 21 ++++- .../layout/LayoutProvider/LayoutProvider.tsx | 20 ++-- .../hooks/useCurrentActiveMediaQuery.tsx | 23 +---- .../layout/utils/makeLayoutDefaultTheme.ts | 18 ---- .../layout/utils/overrideLayoutTheme.ts | 16 ++++ src/components/theme/ThemeProvider.tsx | 9 +- .../theme/__stories__/Theme.stories.tsx | 92 +++++++++++++------ src/components/theme/getDarkMediaMatch.ts | 2 + src/components/theme/getSystemTheme.ts | 4 +- src/components/theme/types.ts | 1 + src/components/theme/useSystemTheme.ts | 6 +- 14 files changed, 189 insertions(+), 140 deletions(-) create mode 100644 src/components/Portal/Portal.scss delete mode 100644 src/components/layout/utils/makeLayoutDefaultTheme.ts create mode 100644 src/components/layout/utils/overrideLayoutTheme.ts diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 2c220ee022..7d14950d1b 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -102,34 +102,32 @@ export function Modal({ }); return ( - - - containerRef.current?.addEventListener('animationend', done) - } - classNames={getCSSTransitionClassNames(b)} - mountOnEnter={!keepMounted} - unmountOnExit={!keepMounted} - appear={true} - onEnter={() => { - setInTransition(true); - onTransitionEnter?.(); - }} - onExit={() => { - setInTransition(true); - onTransitionExit?.(); - }} - onEntered={() => { - setInTransition(false); - onTransitionEntered?.(); - }} - onExited={() => { - setInTransition(false); - onTransitionExited?.(); - }} - > + containerRef.current?.addEventListener('animationend', done)} + classNames={getCSSTransitionClassNames(b)} + mountOnEnter={!keepMounted} + unmountOnExit={!keepMounted} + appear={true} + onEnter={() => { + setInTransition(true); + onTransitionEnter?.(); + }} + onExit={() => { + setInTransition(true); + onTransitionExit?.(); + }} + onEntered={() => { + setInTransition(false); + onTransitionEntered?.(); + }} + onExited={() => { + setInTransition(false); + onTransitionExited?.(); + }} + > +
@@ -157,7 +155,7 @@ export function Modal({
-
-
+ + ); } diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index f0e436b897..b25b2e03cb 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -151,30 +151,28 @@ export function Popup({ }); return ( - - - containerRef.current?.addEventListener('animationend', done) - } - classNames={getCSSTransitionClassNames(b)} - mountOnEnter={!keepMounted} - unmountOnExit={!keepMounted} - appear={true} - onEnter={() => { - onTransitionEnter?.(); - }} - onEntered={() => { - onTransitionEntered?.(); - }} - onExit={() => { - onTransitionExit?.(); - }} - onExited={() => { - onTransitionExited?.(); - }} - > + containerRef.current?.addEventListener('animationend', done)} + classNames={getCSSTransitionClassNames(b)} + mountOnEnter={!keepMounted} + unmountOnExit={!keepMounted} + appear={true} + onEnter={() => { + onTransitionEnter?.(); + }} + onEntered={() => { + onTransitionEntered?.(); + }} + onExit={() => { + onTransitionExit?.(); + }} + onExited={() => { + onTransitionExited?.(); + }} + > +
-
-
+ + ); } diff --git a/src/components/Portal/Portal.scss b/src/components/Portal/Portal.scss new file mode 100644 index 0000000000..58fa6954a2 --- /dev/null +++ b/src/components/Portal/Portal.scss @@ -0,0 +1,9 @@ +@use '../variables'; + +$block: '.#{variables.$ns}portal'; + +#{$block} { + &__theme-wrapper { + display: contents; + } +} diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index cbc0bdb7ea..28c3bcb8be 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -3,6 +3,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {usePortalContainer} from '../../hooks'; +import {ThemeProvider} from '../theme'; +import {useThemeContext} from '../theme/useThemeContext'; +import {block} from '../utils/cn'; + +import './Portal.scss'; + +const b = block('portal'); export interface PortalProps { container?: HTMLElement; @@ -12,6 +19,7 @@ export interface PortalProps { export function Portal({container, children, disablePortal}: PortalProps) { const defaultContainer = usePortalContainer(); + const {scoped} = useThemeContext(); const containerNode = container ?? defaultContainer; @@ -19,5 +27,16 @@ export function Portal({container, children, disablePortal}: PortalProps) { return {children}; } - return containerNode ? ReactDOM.createPortal(children, containerNode) : null; + return containerNode + ? ReactDOM.createPortal( + scoped ? ( + + {children} + + ) : ( + children + ), + containerNode, + ) + : null; } diff --git a/src/components/layout/LayoutProvider/LayoutProvider.tsx b/src/components/layout/LayoutProvider/LayoutProvider.tsx index 37cc58fa07..33addad11e 100644 --- a/src/components/layout/LayoutProvider/LayoutProvider.tsx +++ b/src/components/layout/LayoutProvider/LayoutProvider.tsx @@ -4,7 +4,7 @@ import React from 'react'; import {LayoutContext} from '../contexts/LayoutContext'; import {useCurrentActiveMediaQuery} from '../hooks/useCurrentActiveMediaQuery'; import type {LayoutTheme, MediaType, RecursivePartial} from '../types'; -import {makeLayoutDefaultTheme} from '../utils/makeLayoutDefaultTheme'; +import {overrideLayoutTheme} from '../utils/overrideLayoutTheme'; export interface PrivateLayoutProviderProps { config?: RecursivePartial; @@ -20,19 +20,15 @@ export function PrivateLayoutProvider({ config: override, initialMediaQuery, }: PrivateLayoutProviderProps) { - const theme = React.useMemo(() => makeLayoutDefaultTheme({override}), [override]); + const parentContext = React.useContext(LayoutContext); + const theme = React.useMemo( + () => overrideLayoutTheme({theme: parentContext.theme, override}), + [override, parentContext.theme], + ); const activeMediaQuery = useCurrentActiveMediaQuery(theme.breakpoints, initialMediaQuery); - return ( - - {children} - - ); + const value = React.useMemo(() => ({activeMediaQuery, theme}), [activeMediaQuery, theme]); + return {children}; } interface LayoutProviderProps { diff --git a/src/components/layout/hooks/useCurrentActiveMediaQuery.tsx b/src/components/layout/hooks/useCurrentActiveMediaQuery.tsx index f58f4ed412..14be78a08d 100644 --- a/src/components/layout/hooks/useCurrentActiveMediaQuery.tsx +++ b/src/components/layout/hooks/useCurrentActiveMediaQuery.tsx @@ -25,12 +25,12 @@ export const makeCurrentActiveMediaExpressions = ( xxxl: `(min-width: ${mediaToValue.xxxl}px)`, }); -const safeMatchMedia = (query: string | number): MediaQueryList => { +const safeMatchMedia = (query: string): MediaQueryList => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return mockMediaQueryList; } - return window.matchMedia(String(query)); + return window.matchMedia(query); }; class Queries { @@ -81,33 +81,20 @@ export const useCurrentActiveMediaQuery = ( const [state, _setState] = React.useState(initialMediaQuery); React.useLayoutEffect(() => { - let mounted = true; - const queries = new Queries(breakpointsMap); const setState = () => { _setState(queries.getCurrentActiveMedia()); }; - const onChange = () => { - if (!mounted) { - return; - } - - setState(); - }; - - queries.addListeners(onChange); + queries.addListeners(setState); setState(); return () => { - mounted = false; - queries.removeListeners(onChange); + queries.removeListeners(setState); }; - // don't support runtime breakpoint redefinition. Breakpoints defined only one at LayoutTheme - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [breakpointsMap]); return state; }; diff --git a/src/components/layout/utils/makeLayoutDefaultTheme.ts b/src/components/layout/utils/makeLayoutDefaultTheme.ts deleted file mode 100644 index 7c20cdb14c..0000000000 --- a/src/components/layout/utils/makeLayoutDefaultTheme.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable valid-jsdoc */ -import merge from 'lodash/merge'; - -import {DEFAULT_LAYOUT_THEME} from '../constants'; -import type {LayoutTheme, RecursivePartial} from '../types'; - -interface MakeDefaultLayoutTheme { - override?: RecursivePartial; -} - -/** - * Use this function to override default `DEFAULT_LAYOUT_THEME` - */ -export const makeLayoutDefaultTheme = ({ - override, -}: MakeDefaultLayoutTheme | undefined = {}): LayoutTheme => { - return merge(DEFAULT_LAYOUT_THEME, override); -}; diff --git a/src/components/layout/utils/overrideLayoutTheme.ts b/src/components/layout/utils/overrideLayoutTheme.ts new file mode 100644 index 0000000000..1cd6501bac --- /dev/null +++ b/src/components/layout/utils/overrideLayoutTheme.ts @@ -0,0 +1,16 @@ +/* eslint-disable valid-jsdoc */ +import merge from 'lodash/merge'; + +import type {LayoutTheme, RecursivePartial} from '../types'; + +interface OverrideLayoutThemeOptions { + theme: LayoutTheme; + override?: RecursivePartial; +} + +/** + * Use this function to override default `DEFAULT_LAYOUT_THEME` + */ +export function overrideLayoutTheme({theme, override}: OverrideLayoutThemeOptions): LayoutTheme { + return merge(theme, override); +} diff --git a/src/components/theme/ThemeProvider.tsx b/src/components/theme/ThemeProvider.tsx index c982e03c1c..c0690affe0 100644 --- a/src/components/theme/ThemeProvider.tsx +++ b/src/components/theme/ThemeProvider.tsx @@ -80,8 +80,9 @@ export function ThemeProvider({ theme, themeValue, direction, + scoped, }) satisfies ThemeContextProps, - [theme, themeValue, direction], + [theme, themeValue, direction, scoped], ); const themeSettingsContext = React.useMemo( @@ -102,11 +103,7 @@ export function ThemeProvider({ }, rootClassName, )} - dir={ - hasParentProvider && direction === parentDirection - ? undefined - : direction - } + dir={direction} > {children} diff --git a/src/components/theme/__stories__/Theme.stories.tsx b/src/components/theme/__stories__/Theme.stories.tsx index 38cd6ed0ac..b43b88ab24 100644 --- a/src/components/theme/__stories__/Theme.stories.tsx +++ b/src/components/theme/__stories__/Theme.stories.tsx @@ -3,7 +3,10 @@ import React from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../Button'; +import {Dialog} from '../../Dialog'; +import {Select} from '../../Select'; import {Text} from '../../Text'; +import {Tooltip} from '../../Tooltip'; import {ThemeProvider} from '../ThemeProvider'; import {useDirection} from '../useDirection'; @@ -11,24 +14,9 @@ const meta: Meta = { title: 'Components/Utils/ThemeProvider', component: ThemeProvider, tags: ['nodocs'], - argTypes: { - theme: { - options: ['none', 'light', 'dark', 'light-hc', 'dark-hc', 'system'], - control: { - type: 'select', - }, - mapping: { - none: undefined, - }, - }, - direction: { - options: ['none', 'ltr', 'rtl'], - control: { - type: 'radio', - }, - mapping: { - none: undefined, - }, + parameters: { + controls: { + disable: true, }, }, }; @@ -38,22 +26,74 @@ export default meta; type Story = StoryObj; function ScopedComponent() { + const [open, setOpen] = React.useState(false); return ( -
- +
+ + + + setOpen(false)}> + + + Dialog.Body + + +
); } export const Scoped: Story = { render: function ThemeScoped(props) { + const style: React.CSSProperties = { + border: '1px red dotted', + padding: 10, + height: '100%', + boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 10, + }; return ( -
- - - -
- Inside scoped theme provider +
+ +
+ Inside scoped theme provider (light) + +
+
+ +
+ Inside scoped theme provider (dark) + +
+
+ +
+ Inside scoped theme provider (light-hc) + +
+
+ +
+ Inside scoped theme provider (dark-hc)
diff --git a/src/components/theme/getDarkMediaMatch.ts b/src/components/theme/getDarkMediaMatch.ts index 5bdf5f7cfa..9099dc4aa2 100644 --- a/src/components/theme/getDarkMediaMatch.ts +++ b/src/components/theme/getDarkMediaMatch.ts @@ -1 +1,3 @@ +export const supportsMatchMedia = + typeof window !== 'undefined' && typeof window.matchMedia === 'function'; export const getDarkMediaMatch = () => window.matchMedia('(prefers-color-scheme: dark)'); diff --git a/src/components/theme/getSystemTheme.ts b/src/components/theme/getSystemTheme.ts index 10fc693bf5..19c35ce446 100644 --- a/src/components/theme/getSystemTheme.ts +++ b/src/components/theme/getSystemTheme.ts @@ -1,7 +1,7 @@ -import {getDarkMediaMatch} from './getDarkMediaMatch'; +import {getDarkMediaMatch, supportsMatchMedia} from './getDarkMediaMatch'; export function getSystemTheme() { - if (typeof window === 'object') { + if (supportsMatchMedia) { return getDarkMediaMatch().matches ? 'dark' : 'light'; } else { return 'light'; diff --git a/src/components/theme/types.ts b/src/components/theme/types.ts index 13940271b2..c3551f521b 100644 --- a/src/components/theme/types.ts +++ b/src/components/theme/types.ts @@ -8,4 +8,5 @@ export interface ThemeContextProps { theme: Theme; themeValue: RealTheme; direction: Direction; + scoped?: boolean; } diff --git a/src/components/theme/useSystemTheme.ts b/src/components/theme/useSystemTheme.ts index a2fe53b2dd..8deca3e2bf 100644 --- a/src/components/theme/useSystemTheme.ts +++ b/src/components/theme/useSystemTheme.ts @@ -1,6 +1,6 @@ import React from 'react'; -import {getDarkMediaMatch} from './getDarkMediaMatch'; +import {getDarkMediaMatch, supportsMatchMedia} from './getDarkMediaMatch'; import {getSystemTheme} from './getSystemTheme'; import type {ThemeType} from './types'; @@ -29,6 +29,10 @@ export function useSystemTheme(): ThemeType { const [theme, setTheme] = React.useState(getSystemTheme()); React.useEffect(() => { + if (!supportsMatchMedia) { + return undefined; + } + function onChange(event: MediaQueryListEvent) { setTheme(event.matches ? 'dark' : 'light'); }