From 8e849ae88b2e9f6a297cc13c404c802b22686876 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Wed, 17 Jan 2024 11:04:03 +0100 Subject: [PATCH] [Avatar] Use variants api (#40324) --- apps/zero-runtime-next-app/next.config.js | 6 - .../src/app/avatar/page.tsx | 12 + apps/zero-runtime-next-app/src/app/layout.tsx | 7 +- apps/zero-runtime-next-app/src/app/page.tsx | 2 + .../src/components/Avatar/Avatar.js | 272 ++++++++++++++++++ .../mui-joy/src/styles/defaultTheme.test.js | 1 + .../mui-joy/src/styles/extendTheme.test.js | 1 + packages/mui-joy/src/styles/extendTheme.ts | 18 ++ packages/mui-joy/src/styles/types/theme.ts | 1 + .../src/Switch/Switch.types.ts | 4 +- packages/mui-material/src/Avatar/Avatar.js | 47 +-- .../mui-material/src/styles/createTheme.d.ts | 1 + .../mui-material/src/styles/createTheme.js | 15 + .../src/styles/createTheme.test.js | 48 +++- .../styles/experimental_extendTheme.test.js | 25 ++ 15 files changed, 427 insertions(+), 33 deletions(-) create mode 100644 apps/zero-runtime-next-app/src/app/avatar/page.tsx create mode 100644 apps/zero-runtime-next-app/src/components/Avatar/Avatar.js diff --git a/apps/zero-runtime-next-app/next.config.js b/apps/zero-runtime-next-app/next.config.js index 3524d4f9240c77..283752eae66cba 100644 --- a/apps/zero-runtime-next-app/next.config.js +++ b/apps/zero-runtime-next-app/next.config.js @@ -5,12 +5,6 @@ const { experimental_extendTheme: extendTheme } = require('@mui/material/styles' const theme = extendTheme({ cssVarPrefix: 'app' }); -theme.applyDarkStyles = function applyDarkStyles(obj) { - return { - ':where([data-mui-color-scheme="dark"]) &': obj, - }; -}; - /** * @typedef {import('@mui/zero-next-plugin').ZeroPluginConfig} ZeroPluginConfig */ diff --git a/apps/zero-runtime-next-app/src/app/avatar/page.tsx b/apps/zero-runtime-next-app/src/app/avatar/page.tsx new file mode 100644 index 00000000000000..d4f79567a58504 --- /dev/null +++ b/apps/zero-runtime-next-app/src/app/avatar/page.tsx @@ -0,0 +1,12 @@ +import Stack from '@mui/material/Stack'; +import Avatar from '@/components/Avatar/Avatar'; + +export default function Avatars() { + return ( + + + + + + ); +} diff --git a/apps/zero-runtime-next-app/src/app/layout.tsx b/apps/zero-runtime-next-app/src/app/layout.tsx index bb634f29fa01e5..386d6dccbc2cb9 100644 --- a/apps/zero-runtime-next-app/src/app/layout.tsx +++ b/apps/zero-runtime-next-app/src/app/layout.tsx @@ -16,15 +16,10 @@ const Html = styled.html({ color: 'red', }); -const Body = styled.body({ - color: 'rgb(var(--foreground-rgb))', - background: `linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb))`, -}); - export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); } diff --git a/apps/zero-runtime-next-app/src/app/page.tsx b/apps/zero-runtime-next-app/src/app/page.tsx index 59edcbe6c33e14..bba7f57f7f6074 100644 --- a/apps/zero-runtime-next-app/src/app/page.tsx +++ b/apps/zero-runtime-next-app/src/app/page.tsx @@ -3,6 +3,8 @@ import { styled } from '@mui/zero-runtime'; import styles from './page.module.css'; const Main = styled.main({ + color: 'rgb(var(--foreground-rgb))', + background: `linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb))`, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', diff --git a/apps/zero-runtime-next-app/src/components/Avatar/Avatar.js b/apps/zero-runtime-next-app/src/components/Avatar/Avatar.js new file mode 100644 index 00000000000000..c5fa7151c6323f --- /dev/null +++ b/apps/zero-runtime-next-app/src/components/Avatar/Avatar.js @@ -0,0 +1,272 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses'; +import { styled } from '@mui/zero-runtime'; +import { useThemeProps } from '@mui/material/styles'; +/* eslint-disable-next-line no-restricted-imports */ +import Person from '@mui/material/internal/svg-icons/Person'; +import { getAvatarUtilityClass } from '@mui/material/Avatar'; + +const useUtilityClasses = (ownerState) => { + const { classes, variant, colorDefault } = ownerState; + + const slots = { + root: ['root', variant, colorDefault && 'colorDefault'], + img: ['img'], + fallback: ['fallback'], + }; + + return composeClasses(slots, getAvatarUtilityClass, classes); +}; + +const AvatarRoot = styled('div', { + name: 'MuiAvatar', + slot: 'Root', + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + styles.root, + styles[ownerState.variant], + ownerState.colorDefault && styles.colorDefault, + ]; + }, +})(({ theme }) => ({ + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: 40, + height: 40, + fontFamily: theme.typography.fontFamily, + fontSize: theme.typography.pxToRem(20), + lineHeight: 1, + borderRadius: '50%', + overflow: 'hidden', + userSelect: 'none', + variants: [ + { + props: { variant: 'rounded' }, + style: { + borderRadius: (theme.vars || theme).shape.borderRadius, + }, + }, + { + props: { variant: 'square' }, + style: { + borderRadius: 0, + }, + }, + { + props: { colorDefault: true }, + style: { + color: (theme.vars || theme).palette.background.default, + ...(theme.vars + ? { + backgroundColor: theme.vars.palette.Avatar.defaultBg, + } + : { + backgroundColor: theme.palette.grey[400], + ...theme.applyDarkStyles({ backgroundColor: theme.palette.grey[600] }), + }), + }, + }, + ], +})); + +const AvatarImg = styled('img', { + name: 'MuiAvatar', + slot: 'Img', + overridesResolver: (props, styles) => styles.img, +})({ + width: '100%', + height: '100%', + textAlign: 'center', + // Handle non-square image. The property isn't supported by IE11. + objectFit: 'cover', + // Hide alt text. + color: 'transparent', + // Hide the image broken icon, only works on Chrome. + textIndent: 10000, +}); + +const AvatarFallback = styled(Person, { + name: 'MuiAvatar', + slot: 'Fallback', + overridesResolver: (props, styles) => styles.fallback, +})({ + width: '75%', + height: '75%', +}); + +function useLoaded({ crossOrigin, referrerPolicy, src, srcSet }) { + const [loaded, setLoaded] = React.useState(false); + + React.useEffect(() => { + if (!src && !srcSet) { + return undefined; + } + + setLoaded(false); + + let active = true; + const image = new Image(); + image.onload = () => { + if (!active) { + return; + } + setLoaded('loaded'); + }; + image.onerror = () => { + if (!active) { + return; + } + setLoaded('error'); + }; + image.crossOrigin = crossOrigin; + image.referrerPolicy = referrerPolicy; + image.src = src; + if (srcSet) { + image.srcset = srcSet; + } + + return () => { + active = false; + }; + }, [crossOrigin, referrerPolicy, src, srcSet]); + + return loaded; +} + +const Avatar = React.forwardRef(function Avatar(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'MuiAvatar' }); + const { + alt, + children: childrenProp, + className, + component = 'div', + imgProps, + sizes, + src, + srcSet, + variant = 'circular', + ...other + } = props; + + let children = null; + + // Use a hook instead of onError on the img element to support server-side rendering. + const loaded = useLoaded({ ...imgProps, src, srcSet }); + const hasImg = src || srcSet; + const hasImgNotFailing = hasImg && loaded !== 'error'; + + const ownerState = { + ...props, + colorDefault: !hasImgNotFailing, + component, + variant, + }; + + const classes = useUtilityClasses(ownerState); + + if (hasImgNotFailing) { + children = ( + + ); + } else if (childrenProp != null) { + children = childrenProp; + } else if (hasImg && alt) { + children = alt[0]; + } else { + children = ; + } + + return ( + + {children} + + ); +}); + +Avatar.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the d.ts file and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * Used in combination with `src` or `srcSet` to + * provide an alt attribute for the rendered `img` element. + */ + alt: PropTypes.string, + /** + * Used to render icon or text elements inside the Avatar if `src` is not set. + * This can be an element, or just a string. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The component used for the root node. + * Either a string to use a HTML element or a component. + */ + component: PropTypes.elementType, + /** + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attributes) applied to the `img` element if the component is used to display an image. + * It can be used to listen for the loading error event. + */ + imgProps: PropTypes.object, + /** + * The `sizes` attribute for the `img` element. + */ + sizes: PropTypes.string, + /** + * The `src` attribute for the `img` element. + */ + src: PropTypes.string, + /** + * The `srcSet` attribute for the `img` element. + * Use this attribute for responsive image display. + */ + srcSet: PropTypes.string, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * The shape of the avatar. + * @default 'circular' + */ + variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['circular', 'rounded', 'square']), + PropTypes.string, + ]), +}; + +export default Avatar; diff --git a/packages/mui-joy/src/styles/defaultTheme.test.js b/packages/mui-joy/src/styles/defaultTheme.test.js index 154e11cbd2707a..9caf86ae5ed530 100644 --- a/packages/mui-joy/src/styles/defaultTheme.test.js +++ b/packages/mui-joy/src/styles/defaultTheme.test.js @@ -31,6 +31,7 @@ describe('defaultTheme', () => { 'unstable_sx', 'shouldSkipGeneratingVar', 'generateCssVars', + 'applyDarkStyles', ]).to.includes(field); }); }); diff --git a/packages/mui-joy/src/styles/extendTheme.test.js b/packages/mui-joy/src/styles/extendTheme.test.js index 7568b02be2dbcf..8e1dd13a4cdf05 100644 --- a/packages/mui-joy/src/styles/extendTheme.test.js +++ b/packages/mui-joy/src/styles/extendTheme.test.js @@ -31,6 +31,7 @@ describe('extendTheme', () => { 'unstable_sx', 'shouldSkipGeneratingVar', 'generateCssVars', + 'applyDarkStyles', ]).to.includes(field); }); }); diff --git a/packages/mui-joy/src/styles/extendTheme.ts b/packages/mui-joy/src/styles/extendTheme.ts index 6a4a97ccb7e46e..4c0692664567cd 100644 --- a/packages/mui-joy/src/styles/extendTheme.ts +++ b/packages/mui-joy/src/styles/extendTheme.ts @@ -9,6 +9,7 @@ import { unstable_createGetCssVar as systemCreateGetCssVar, unstable_styleFunctionSx as styleFunctionSx, SxConfig, + CSSObject, } from '@mui/system'; import defaultSxConfig from './sxConfig'; import colors from '../colors'; @@ -564,6 +565,23 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { cssVarPrefix, getCssVar, spacing: createSpacing(spacing), + applyDarkStyles(css: CSSObject) { + if ((this as Theme).vars) { + // If CssVarsProvider is used as a provider, + // returns ':where([data-mui-color-scheme="light|dark"]) &' + const selector = (this as Theme) + .getColorSchemeSelector('dark') + .replace(/(\[[^\]]+\])/, ':where($1)'); + return { + [selector]: css, + }; + } + if ((this as Theme).palette.mode === 'dark') { + return css; + } + + return {}; + }, } as unknown as Theme; // Need type casting due to module augmentation inside the repo /** diff --git a/packages/mui-joy/src/styles/types/theme.ts b/packages/mui-joy/src/styles/types/theme.ts index c380d259ebcc12..68fdd0c39e7f24 100644 --- a/packages/mui-joy/src/styles/types/theme.ts +++ b/packages/mui-joy/src/styles/types/theme.ts @@ -118,6 +118,7 @@ export interface Theme extends ThemeScales, RuntimeColorSystem { shouldSkipGeneratingVar: (keys: string[], value: string | number) => boolean; unstable_sxConfig: SxConfig; unstable_sx: (props: SxProps) => CSSObject; + applyDarkStyles: (css: CSSObject) => CSSObject; } export type SxProps = SystemSxProps; diff --git a/packages/mui-material-next/src/Switch/Switch.types.ts b/packages/mui-material-next/src/Switch/Switch.types.ts index 2d5a89dd053cc2..4eff5796daf7c3 100644 --- a/packages/mui-material-next/src/Switch/Switch.types.ts +++ b/packages/mui-material-next/src/Switch/Switch.types.ts @@ -1,10 +1,10 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; import { OverridableStringUnion, OverrideProps, PartiallyRequired } from '@mui/types'; -import { Theme } from '@mui/material/styles'; // eslint-disable-next-line no-restricted-imports import { InternalStandardProps as StandardProps } from '@mui/material'; import type { SwitchBaseProps } from '@mui/material/internal/SwitchBase'; +import { Theme } from '../styles'; import { SwitchClasses } from './switchClasses'; export interface SwitchPropsSizeOverrides {} @@ -12,7 +12,7 @@ export interface SwitchPropsSizeOverrides {} export interface SwitchPropsColorOverrides {} export interface SwitchOwnProps - extends StandardProps { + extends StandardProps { /** * The icon to display when the component is checked. */ diff --git a/packages/mui-material/src/Avatar/Avatar.js b/packages/mui-material/src/Avatar/Avatar.js index a11c6cf17462fc..e1635672b37cb7 100644 --- a/packages/mui-material/src/Avatar/Avatar.js +++ b/packages/mui-material/src/Avatar/Avatar.js @@ -32,7 +32,7 @@ const AvatarRoot = styled('div', { ownerState.colorDefault && styles.colorDefault, ]; }, -})(({ theme, ownerState }) => ({ +})(({ theme }) => ({ position: 'relative', display: 'flex', alignItems: 'center', @@ -46,23 +46,34 @@ const AvatarRoot = styled('div', { borderRadius: '50%', overflow: 'hidden', userSelect: 'none', - ...(ownerState.variant === 'rounded' && { - borderRadius: (theme.vars || theme).shape.borderRadius, - }), - ...(ownerState.variant === 'square' && { - borderRadius: 0, - }), - ...(ownerState.colorDefault && { - color: (theme.vars || theme).palette.background.default, - ...(theme.vars - ? { - backgroundColor: theme.vars.palette.Avatar.defaultBg, - } - : { - backgroundColor: - theme.palette.mode === 'light' ? theme.palette.grey[400] : theme.palette.grey[600], - }), - }), + variants: [ + { + props: { variant: 'rounded' }, + style: { + borderRadius: (theme.vars || theme).shape.borderRadius, + }, + }, + { + props: { variant: 'square' }, + style: { + borderRadius: 0, + }, + }, + { + props: { colorDefault: true }, + style: { + color: (theme.vars || theme).palette.background.default, + ...(theme.vars + ? { + backgroundColor: theme.vars.palette.Avatar.defaultBg, + } + : { + backgroundColor: theme.palette.grey[400], + ...theme.applyDarkStyles({ backgroundColor: theme.palette.grey[600] }), + }), + }, + }, + ], })); const AvatarImg = styled('img', { diff --git a/packages/mui-material/src/styles/createTheme.d.ts b/packages/mui-material/src/styles/createTheme.d.ts index ef8cb895b1fb14..922db0c76cf64b 100644 --- a/packages/mui-material/src/styles/createTheme.d.ts +++ b/packages/mui-material/src/styles/createTheme.d.ts @@ -45,6 +45,7 @@ export interface Theme extends BaseTheme { components?: Components; unstable_sx: (props: SxProps) => CSSObject; unstable_sxConfig: SxConfig; + applyDarkStyles: (css: CSSObject) => CSSObject; } /** diff --git a/packages/mui-material/src/styles/createTheme.js b/packages/mui-material/src/styles/createTheme.js index 39cba1f2731a1f..dc30e2cee9bf97 100644 --- a/packages/mui-material/src/styles/createTheme.js +++ b/packages/mui-material/src/styles/createTheme.js @@ -43,6 +43,21 @@ function createTheme(options = {}, ...args) { typography: createTypography(palette, typographyInput), transitions: createTransitions(transitionsInput), zIndex: { ...zIndex }, + applyDarkStyles(css) { + if (this.vars) { + // If CssVarsProvider is used as a provider, + // returns ':where([data-mui-color-scheme="light|dark"]) &' + const selector = this.getColorSchemeSelector('dark').replace(/(\[[^\]]+\])/, ':where($1)'); + return { + [selector]: css, + }; + } + if (this.palette.mode === 'dark') { + return css; + } + + return {}; + }, }); muiTheme = deepmerge(muiTheme, other); diff --git a/packages/mui-material/src/styles/createTheme.test.js b/packages/mui-material/src/styles/createTheme.test.js index d9737b9a3c52e1..953c0f70a02725 100644 --- a/packages/mui-material/src/styles/createTheme.test.js +++ b/packages/mui-material/src/styles/createTheme.test.js @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { createRenderer } from '@mui-internal/test-utils'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { ThemeProvider, createTheme, styled } from '@mui/material/styles'; import { deepOrange, green } from '@mui/material/colors'; describe('createTheme', () => { @@ -251,6 +251,52 @@ describe('createTheme', () => { }); }); + it('should apply dark styles when using applyDarkStyles if mode="dark"', function test() { + const darkTheme = createTheme({ + palette: { + mode: 'dark', + }, + }); + + const Test = styled('div')(({ theme }) => ({ + backgroundColor: 'rgb(255, 255, 255)', + ...theme.applyDarkStyles({ + backgroundColor: 'rgb(0, 0, 0)', + }), + })); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toHaveComputedStyle({ + backgroundColor: 'rgb(0, 0, 0)', + }); + }); + + it('should apply dark styles when using applyDarkStyles if mode="light"', function test() { + const lightTheme = createTheme(); + + const Test = styled('div')(({ theme }) => ({ + backgroundColor: 'rgb(255, 255, 255)', + ...theme.applyDarkStyles({ + backgroundColor: 'rgb(0, 0, 0)', + }), + })); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toHaveComputedStyle({ + backgroundColor: 'rgb(255, 255, 255)', + }); + }); + it('Throw an informative error when the key `vars` is passed as part of `options` passed', () => { try { createTheme({ diff --git a/packages/mui-material/src/styles/experimental_extendTheme.test.js b/packages/mui-material/src/styles/experimental_extendTheme.test.js index 04b73a0f7eebd0..a0642c14681398 100644 --- a/packages/mui-material/src/styles/experimental_extendTheme.test.js +++ b/packages/mui-material/src/styles/experimental_extendTheme.test.js @@ -5,6 +5,7 @@ import Button from '@mui/material/Button'; import { Experimental_CssVarsProvider as CssVarsProvider, experimental_extendTheme as extendTheme, + styled, } from '@mui/material/styles'; import { deepOrange, green } from '@mui/material/colors'; @@ -503,4 +504,28 @@ describe('experimental_extendTheme', () => { expect(theme[key]).to.deep.equal(theme.vars[key]); }); }); + + it('should use the right selector with applyDarkStyles', function test() { + const defaultTheme = extendTheme(); + const attribute = 'data-custom-color-scheme'; + let darkStyles = {}; + const Test = styled('div')(({ theme }) => { + darkStyles = theme.applyDarkStyles({ + backgroundColor: 'rgba(0, 0, 0, 0)', + }); + return null; + }); + + render( + + + , + ); + + expect(darkStyles).to.deep.equal({ + [`:where([${attribute}="dark"]) &`]: { + backgroundColor: 'rgba(0, 0, 0, 0)', + }, + }); + }); });