diff --git a/docs/pages/experiments/joy/switch.tsx b/docs/pages/experiments/joy/switch.tsx new file mode 100644 index 00000000000000..49e7a349fb18e2 --- /dev/null +++ b/docs/pages/experiments/joy/switch.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +// @ts-ignore +import { jsx as _jsx } from 'react/jsx-runtime'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Switch from '@mui/joy/Switch'; +import { + CssVarsProvider, + styled, + useColorScheme, + ColorPaletteProp, + TypographySystem, + FontSize, +} from '@mui/joy/styles'; + +export const SvgIcon = styled('svg', { + shouldForwardProp: (prop) => prop !== 'fontSize' && prop !== 'sx', +})<{ + fontSize: keyof FontSize | 'inherit'; +}>(({ theme, fontSize }) => ({ + userSelect: 'none', + width: '1em', + height: '1em', + display: 'inline-block', + fill: 'currentColor', + flexShrink: 0, + ...(fontSize && { + fontSize: fontSize === 'inherit' ? 'inherit' : theme.vars.fontSize[fontSize], + }), +})); + +function createSvgIcon(path: any, displayName: any, initialProps?: any) { + const Component = (props: any, ref: any) => + ( + + {path} + + ) as unknown as typeof SvgIcon; + + // @ts-ignore + return React.memo(React.forwardRef(Component)); +} + +const Typography = styled('p', { + shouldForwardProp: (prop) => prop !== 'color' && prop !== 'level' && prop !== 'sx', +})<{ color?: ColorPaletteProp; level?: keyof TypographySystem }>( + ({ theme, level = 'body1', color }) => [ + { margin: 0 }, + theme.typography[level], + color && { color: `var(--joy-palette-${color}-textColor)` }, + ], +); + +export const Moon = createSvgIcon( + _jsx('path', { + d: 'M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z', + }), + 'DarkMode', +); + +export const Sun = createSvgIcon( + _jsx('path', { + d: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z', + }), + 'LightMode', +); + +const ColorSchemePicker = () => { + const { mode, setMode } = useColorScheme(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { + setMounted(true); + }, []); + if (!mounted) { + return null; + } + + return ( + + ); +}; + +const props = { + size: ['sm', 'md', 'lg'], + color: ['primary', 'danger', 'info', 'success', 'warning'], +} as const; + +export default function JoySwitch() { + return ( + + + + + + + {Object.entries(props).map(([propName, propValue]) => ( + + {propName} + {propValue.map((value) => ( + + + {value && ( + + {value} + + )} + + ))} + + ))} + + + + ); +} diff --git a/packages/mui-joy/src/Switch/Switch.spec.tsx b/packages/mui-joy/src/Switch/Switch.spec.tsx new file mode 100644 index 00000000000000..131bd1329907a3 --- /dev/null +++ b/packages/mui-joy/src/Switch/Switch.spec.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import Switch from '@mui/joy/Switch'; + +; + +; + +; + +// common HTML attributes + {}} />; + +; + +; + + { + const checked = event.target.checked; + }} +/>; + +; +; +; +; +; +// @ts-expect-error there is no neutral switch +; + +; +; +; + +; diff --git a/packages/mui-joy/src/Switch/Switch.test.js b/packages/mui-joy/src/Switch/Switch.test.js new file mode 100644 index 00000000000000..53478b34da225c --- /dev/null +++ b/packages/mui-joy/src/Switch/Switch.test.js @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { describeConformance, act, createRenderer, fireEvent, screen } from 'test/utils'; +import Switch, { switchClasses as classes } from '@mui/joy/Switch'; +import { ThemeProvider } from '@mui/joy/styles'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + classes, + render, + ThemeProvider, + muiName: 'MuiSwitch', + testDeepOverrides: [ + { slotName: 'track', slotClassName: classes.track }, + { slotName: 'input', slotClassName: classes.input }, + ], + refInstanceof: window.HTMLSpanElement, + skip: [ + 'componentProp', + 'componentsProp', + 'classesRoot', + 'propsSpread', + 'themeDefaultProps', + 'themeVariants', + ], + })); + + it('should pass componentProps down to slots', () => { + const { + container: { firstChild: root }, + } = render( + , + ); + + expect(screen.getByTestId('root-switch')).toBeVisible(); + expect(root.childNodes[0]).to.have.class(/custom-(thumb|track|input)/); + expect(root.childNodes[1]).to.have.class(/custom-(thumb|track|input)/); + expect(root.childNodes[2]).to.have.class(/custom-(thumb|track|input)/); + }); + + it('should have the classes required for Switch', () => { + expect(classes).to.include.all.keys(['root', 'checked', 'disabled']); + }); + + it('should render the track as the first child of the Switch', () => { + const { + container: { firstChild: root }, + } = render(); + + expect(root.childNodes[0]).to.have.property('tagName', 'SPAN'); + expect(root.childNodes[0]).to.have.class(classes.track); + }); + + it('renders a `role="checkbox"` with the Unchecked state by default', () => { + const { getByRole } = render(); + + expect(getByRole('checkbox')).to.have.property('checked', false); + }); + + it('renders a checkbox with the Checked state when checked', () => { + const { getByRole } = render(); + + expect(getByRole('checkbox')).to.have.property('checked', true); + }); + + it('the switch can be disabled', () => { + const { getByRole } = render(); + + expect(getByRole('checkbox')).to.have.property('disabled', true); + }); + + it('the switch can be readonly', () => { + const { getByRole } = render(); + + expect(getByRole('checkbox')).to.have.property('readOnly', true); + }); + + it('the Checked state changes after change events', () => { + const { getByRole } = render(); + + // how a user would trigger it + act(() => { + getByRole('checkbox').click(); + fireEvent.change(getByRole('checkbox'), { target: { checked: '' } }); + }); + + expect(getByRole('checkbox')).to.have.property('checked', false); + }); +}); diff --git a/packages/mui-joy/src/Switch/Switch.tsx b/packages/mui-joy/src/Switch/Switch.tsx new file mode 100644 index 00000000000000..a0b0b09756d1f5 --- /dev/null +++ b/packages/mui-joy/src/Switch/Switch.tsx @@ -0,0 +1,280 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { unstable_composeClasses as composeClasses } from '@mui/base'; +import { useSwitch } from '@mui/base/SwitchUnstyled'; +import { styled } from '../styles'; +import switchClasses, { getSwitchUtilityClass } from './switchClasses'; +import { SwitchProps } from './SwitchProps'; + +const useUtilityClasses = (ownerState: SwitchProps & { focusVisible: boolean }) => { + const { classes, checked, disabled, focusVisible, readOnly } = ownerState; + + const slots = { + root: [ + 'root', + checked && 'checked', + disabled && 'disabled', + focusVisible && 'focusVisible', + readOnly && 'readOnly', + ], + thumb: ['thumb', checked && 'checked'], + track: ['track', checked && 'checked'], + input: ['input'], + }; + + return composeClasses(slots, getSwitchUtilityClass, classes); +}; + +const SwitchRoot = styled('span', { + name: 'MuiSwitch', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})<{ ownerState: SwitchProps }>(({ theme, ownerState }) => { + return { + '--Switch-track-radius': theme.vars.radius.lg, + '--Switch-track-width': '48px', + '--Switch-track-height': '24px', + '--Switch-thumb-size': '16px', + ...(ownerState.size === 'sm' && { + '--Switch-track-width': '40px', + '--Switch-track-height': '20px', + '--Switch-thumb-size': '12px', + }), + ...(ownerState.size === 'lg' && { + '--Switch-track-width': '64px', + '--Switch-track-height': '32px', + '--Switch-thumb-size': '24px', + }), + '--Switch-thumb-radius': 'calc(var(--Switch-track-radius) - 2px)', + '--Switch-thumb-width': 'var(--Switch-thumb-size)', + '--Switch-thumb-offset': + 'max((var(--Switch-track-height) - var(--Switch-thumb-size)) / 2, 0px)', + display: 'inline-block', + width: 'var(--Switch-track-width)', // should have the same width as track because flex parent can stretch SwitchRoot. + borderRadius: 'var(--Switch-track-radius)', + position: 'relative', + padding: + 'calc((var(--Switch-thumb-size) / 2) - (var(--Switch-track-height) / 2)) calc(-1 * var(--Switch-thumb-offset))', + color: theme.vars.palette.neutral.containedBg, + '&:hover': { + color: theme.vars.palette.neutral.containedBg, + }, + [`&.${switchClasses.checked}`]: { + color: theme.vars.palette[ownerState.color!].containedBg, + '&:hover': { + color: theme.vars.palette[ownerState.color!].containedHoverBg, + }, + }, + [`&.${switchClasses.disabled}`]: { + pointerEvents: 'none', + cursor: 'default', + opacity: 0.6, + }, + [`&.${switchClasses.focusVisible}`]: theme.focus.default, + }; +}); + +const SwitchInput = styled('input', { + name: 'MuiSwitch', + slot: 'Input', + overridesResolver: (props, styles) => styles.input, +})<{ ownerState: SwitchProps }>(() => ({ + margin: 0, + height: '100%', + width: '100%', + opacity: 0, + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + cursor: 'pointer', +})); + +const SwitchTrack = styled('span', { + name: 'MuiSwitch', + slot: 'Track', + overridesResolver: (props, styles) => styles.track, +})<{ ownerState: SwitchProps & { focusVisible: boolean } }>(() => ({ + position: 'relative', + color: 'inherit', + height: 'var(--Switch-track-height)', + width: 'var(--Switch-track-width)', + display: 'block', + backgroundColor: 'currentColor', + borderRadius: 'var(--Switch-track-radius)', +})); + +const SwitchThumb = styled('span', { + name: 'MuiSwitch', + slot: 'Thumb', + overridesResolver: (props, styles) => styles.thumb, +})<{ ownerState: SwitchProps }>(() => ({ + transition: 'left 0.2s', + position: 'absolute', + top: '50%', + left: 'calc(50% - var(--Switch-track-width) / 2 + var(--Switch-thumb-width) / 2 + var(--Switch-thumb-offset))', + transform: 'translate(-50%, -50%)', + width: 'var(--Switch-thumb-width)', + height: 'var(--Switch-thumb-size)', + borderRadius: 'var(--Switch-thumb-radius)', + backgroundColor: '#fff', + [`&.${switchClasses.checked}`]: { + left: 'calc(50% + var(--Switch-track-width) / 2 - var(--Switch-thumb-width) / 2 - var(--Switch-thumb-offset))', + }, +})); + +const Switch = React.forwardRef(function Switch(inProps, ref) { + const props = inProps; + const { + checked: checkedProp, + className, + component, + componentsProps = {}, + defaultChecked, + disabled: disabledProp, + onBlur, + onChange, + onFocus, + onFocusVisible, + readOnly: readOnlyProp, + required, + color = 'primary', + size, + ...otherProps + } = props; + + const useSwitchProps = { + checked: checkedProp, + defaultChecked, + disabled: disabledProp, + onBlur, + onChange, + onFocus, + onFocusVisible, + readOnly: readOnlyProp, + }; + + const { getInputProps, checked, disabled, focusVisible, readOnly } = useSwitch(useSwitchProps); + + const ownerState = { + ...props, + checked, + disabled, + focusVisible, + readOnly, + color, + size, + }; + + const classes = useUtilityClasses(ownerState); + + return ( + + + + + + ); +}); + +Switch.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * If `true`, the component is checked. + */ + checked: PropTypes.bool, + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class name applied to the root element. + */ + className: PropTypes.string, + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'primary' + */ + color: PropTypes.oneOf(['danger', 'info', 'primary', 'success', 'warning']), + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + */ + component: PropTypes.elementType, + /** + * The props used for each slot inside the Switch. + * @default {} + */ + componentsProps: PropTypes.object, + /** + * The default checked state. Use when the component is not controlled. + */ + defaultChecked: PropTypes.bool, + /** + * If `true`, the component is disabled. + */ + disabled: PropTypes.bool, + /** + * @ignore + */ + id: PropTypes.string, + /** + * @ignore + */ + onBlur: PropTypes.func, + /** + * Callback fired when the state is changed. + * + * @param {React.ChangeEvent} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (string). + * You can pull out the new checked state by accessing `event.target.checked` (boolean). + */ + onChange: PropTypes.func, + /** + * @ignore + */ + onFocus: PropTypes.func, + /** + * @ignore + */ + onFocusVisible: PropTypes.func, + /** + * If `true`, the component is read only. + */ + readOnly: PropTypes.bool, + /** + * If `true`, the `input` element is required. + */ + required: PropTypes.bool, + /** + * The size of the component. + * @default 'md' + */ + size: PropTypes.oneOf(['sm', 'md', 'lg']), +} as any; + +export default Switch; diff --git a/packages/mui-joy/src/Switch/SwitchProps.ts b/packages/mui-joy/src/Switch/SwitchProps.ts new file mode 100644 index 00000000000000..e4298111914736 --- /dev/null +++ b/packages/mui-joy/src/Switch/SwitchProps.ts @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { OverridableStringUnion } from '@mui/types'; +import { UseSwitchProps } from '@mui/base/SwitchUnstyled'; +import { SwitchClasses } from './switchClasses'; +import { SxProps } from '../styles/defaultTheme'; +import { ColorPaletteProp } from '../styles/types'; + +export interface SwitchPropsColorOverrides {} + +export interface SwitchPropsSizeOverrides {} + +export interface SwitchProps + extends UseSwitchProps, + Omit, keyof UseSwitchProps> { + /** + * Class name applied to the root element. + */ + className?: string; + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + */ + component?: React.ElementType; + /** + * The props used for each slot inside the Switch. + * @default {} + */ + componentsProps?: { + thumb?: React.HTMLAttributes; + input?: React.InputHTMLAttributes; + track?: React.HTMLAttributes; + }; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'primary' + */ + color?: OverridableStringUnion< + Exclude, + SwitchPropsColorOverrides + >; + /** + * The size of the component. + * @default 'md' + */ + size?: OverridableStringUnion<'sm' | 'md' | 'lg', SwitchPropsSizeOverrides>; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; +} diff --git a/packages/mui-joy/src/Switch/index.ts b/packages/mui-joy/src/Switch/index.ts new file mode 100644 index 00000000000000..2c190ccd5f69b4 --- /dev/null +++ b/packages/mui-joy/src/Switch/index.ts @@ -0,0 +1,4 @@ +export { default } from './Switch'; +export { default as switchClasses } from './switchClasses'; +export * from './switchClasses'; +export * from './SwitchProps'; diff --git a/packages/mui-joy/src/Switch/switchClasses.ts b/packages/mui-joy/src/Switch/switchClasses.ts new file mode 100644 index 00000000000000..cceac6965d6213 --- /dev/null +++ b/packages/mui-joy/src/Switch/switchClasses.ts @@ -0,0 +1,63 @@ +import { generateUtilityClass, generateUtilityClasses } from '@mui/base'; + +export interface SwitchClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the internal `SwitchBase` component's `checked` class. */ + checked: string; + /** State class applied to the internal SwitchBase component's disabled class. */ + disabled: string; + /** Styles applied to the internal SwitchBase component's input element. */ + input: string; + /** Styles used to create the thumb passed to the internal `SwitchBase` component `icon` prop. */ + thumb: string; + /** Styles applied to the track element. */ + track: string; + /** Class applied to the root element if the switch has visible focus */ + focusVisible: string; + /** Class applied to the root element if the switch is read-only */ + readOnly: string; + /** Styles applied to the root element if `color="primary"`. */ + colorPrimary: string; + /** Styles applied to the root element if `color="danger"`. */ + colorDanger: string; + /** Styles applied to the root element if `color="info"`. */ + colorInfo: string; + /** Styles applied to the root element if `color="success"`. */ + colorSuccess: string; + /** Styles applied to the root element if `color="warning"`. */ + colorWarning: string; + /** Styles applied to the root element if `size="sm"`. */ + sizeSm: string; + /** Styles applied to the root element if `size="md"`. */ + sizeMd: string; + /** Styles applied to the root element if `size="lg"`. */ + sizeLg: string; +} + +export type SwitchClassKey = keyof SwitchClasses; + +export function getSwitchUtilityClass(slot: string): string { + return generateUtilityClass('MuiSwitch', slot); +} + +const switchClasses: SwitchClasses = generateUtilityClasses('MuiSwitch', [ + 'root', + 'checked', + 'disabled', + 'input', + 'thumb', + 'track', + 'focusVisible', + 'readOnly', + 'colorPrimary', + 'colorDanger', + 'colorInfo', + 'colorSuccess', + 'colorWarning', + 'sizeSm', + 'sizeMd', + 'sizeLg', +]); + +export default switchClasses; diff --git a/packages/mui-joy/src/index.ts b/packages/mui-joy/src/index.ts index 3a8ed6e62c440f..7072cb249343de 100644 --- a/packages/mui-joy/src/index.ts +++ b/packages/mui-joy/src/index.ts @@ -3,3 +3,6 @@ export * from './styles'; export { default as Button } from './Button'; export * from './Button'; + +export { default as Switch } from './Switch'; +export * from './Switch'; diff --git a/packages/mui-joy/src/styles/components.d.ts b/packages/mui-joy/src/styles/components.d.ts index ea60820f1ff1c6..b0d2231051b87a 100644 --- a/packages/mui-joy/src/styles/components.d.ts +++ b/packages/mui-joy/src/styles/components.d.ts @@ -2,6 +2,8 @@ import { CSSInterpolation } from '@mui/system'; import { GlobalStateSlot } from '@mui/base'; import { ButtonProps } from '../Button/ButtonProps'; import { ButtonClassKey } from '../Button/buttonClasses'; +import { SwitchProps } from '../Switch/SwitchProps'; +import { SwitchClassKey } from '../Switch/switchClasses'; export type OverridesStyleRules = Record< ClassKey, @@ -12,9 +14,9 @@ export interface Components { MuiButton?: { defaultProps?: Partial; styleOverrides?: Partial>>; - variants?: Array<{ - props: Partial; - style: CSSInterpolation; - }>; + }; + MuiSwitch?: { + defaultProps?: Partial; + styleOverrides?: Partial>>; }; } diff --git a/packages/mui-system/src/cssVars/createGetThemeVar.ts b/packages/mui-system/src/cssVars/createGetThemeVar.ts index 419fc2cffe93a3..564b4a326edefb 100644 --- a/packages/mui-system/src/cssVars/createGetThemeVar.ts +++ b/packages/mui-system/src/cssVars/createGetThemeVar.ts @@ -6,7 +6,10 @@ export default function createGetThemeVar(prefix: str return `, var(--${prefix ? `${prefix}-` : ''}${vars[0]}${appendVar(...vars.slice(1))})`; } - const getThemeVar = (field: T, ...vars: T[]) => { + const getThemeVar = ( + field: T | AdditionalVars, + ...vars: (T | AdditionalVars)[] + ) => { return `var(--${prefix ? `${prefix}-` : ''}${field}${appendVar(...vars)})`; }; return getThemeVar; diff --git a/packages/mui-system/src/styleFunctionSx/styleFunctionSx.d.ts b/packages/mui-system/src/styleFunctionSx/styleFunctionSx.d.ts index 084a4c47829ee7..7fd21123f81e9f 100644 --- a/packages/mui-system/src/styleFunctionSx/styleFunctionSx.d.ts +++ b/packages/mui-system/src/styleFunctionSx/styleFunctionSx.d.ts @@ -49,6 +49,7 @@ export type SystemStyleObject = | SystemCssProperties | CSSPseudoSelectorProps | CSSSelectorObject + | { [cssVariable: string]: string | number } | null; /** diff --git a/test/regressions/fixtures/SwitchJoy/SwitchJoy.js b/test/regressions/fixtures/SwitchJoy/SwitchJoy.js new file mode 100644 index 00000000000000..1586c8dd08a47a --- /dev/null +++ b/test/regressions/fixtures/SwitchJoy/SwitchJoy.js @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { CssVarsProvider } from '@mui/joy/styles'; +import Box from '@mui/joy/Box'; +import Switch from '@mui/joy/Switch'; + +export default function SwitchJoy() { + return ( + + + {['primary', 'danger', 'info', 'success', 'warning'].map((color) => ( + + ))} + {['sm', 'md', 'lg'].map((size) => ( + + ))} + + + ); +}