Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Joy] Add functional Switch component #30487

Merged
merged 14 commits into from
Jan 7, 2022
138 changes: 138 additions & 0 deletions docs/pages/experiments/joy/switch.tsx
Original file line number Diff line number Diff line change
@@ -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) =>
(
<SvgIcon
data-testid={`${displayName}Icon`}
ref={ref}
viewBox="0 0 24 24"
fontSize="xl"
{...initialProps}
{...props}
sx={{ ...initialProps?.sx, ...props.sx }}
>
{path}
</SvgIcon>
) 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 (
<Button
variant="outlined"
onClick={() => {
if (mode === 'light') {
setMode('dark');
} else {
setMode('light');
}
}}
sx={{ minWidth: 40, p: '0.25rem' }}
>
{mode === 'light' ? <Moon /> : <Sun />}
</Button>
);
};

const props = {
size: ['sm', 'md', 'lg'],
color: ['primary', 'danger', 'info', 'success', 'warning'],
} as const;

export default function JoySwitch() {
return (
<CssVarsProvider>
<Box sx={{ py: 5, maxWidth: { md: 1152, xl: 1536 }, mx: 'auto' }}>
<Box sx={{ px: 3 }}>
<ColorSchemePicker />
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{Object.entries(props).map(([propName, propValue]) => (
<Box
key={propName}
sx={{ display: 'flex', flexDirection: 'column', gap: 5, p: 2, alignItems: 'center' }}
>
<Typography sx={{ textDecoration: 'underline' }}>{propName}</Typography>
{propValue.map((value) => (
<Box>
<Switch defaultChecked {...{ [propName]: value }} />
{value && (
<Typography level="body3" sx={{ textAlign: 'center', mt: '4px' }}>
{value}
</Typography>
)}
</Box>
))}
</Box>
))}
</Box>
</Box>
</CssVarsProvider>
);
}
42 changes: 42 additions & 0 deletions packages/mui-joy/src/Switch/Switch.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as React from 'react';
import Switch from '@mui/joy/Switch';

<Switch />;

<Switch component="div" />;

<Switch data-testid="any" />;

// common HTML attributes
<Switch onDrop={() => {}} />;

<Switch defaultChecked />;

<Switch checked />;

<Switch
onChange={(event) => {
const checked = event.target.checked;
}}
/>;

<Switch color="primary" />;
<Switch color="danger" />;
<Switch color="info" />;
<Switch color="success" />;
<Switch color="warning" />;
// @ts-expect-error there is no neutral switch
<Switch color="neutral" />;

<Switch size="sm" />;
<Switch size="md" />;
<Switch size="lg" />;

<Switch
sx={{
'--joy-Switch-track-radius': '8px',
'--joy-Switch-track-width': '48px',
'--joy-Switch-track-height': '24px',
'--joy-Switch-thumb-size': '16px',
}}
/>;
98 changes: 98 additions & 0 deletions packages/mui-joy/src/Switch/Switch.test.js
Original file line number Diff line number Diff line change
@@ -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('<Switch />', () => {
const { render } = createRenderer();

describeConformance(<Switch />, () => ({
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(
<Switch
data-testid="root-switch"
componentsProps={{
thumb: { className: 'custom-thumb' },
track: { className: 'custom-track' },
input: { className: 'custom-input' },
}}
/>,
);

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(<Switch />);

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(<Switch />);

expect(getByRole('checkbox')).to.have.property('checked', false);
});

it('renders a checkbox with the Checked state when checked', () => {
const { getByRole } = render(<Switch defaultChecked />);

expect(getByRole('checkbox')).to.have.property('checked', true);
});

it('the switch can be disabled', () => {
const { getByRole } = render(<Switch disabled />);

expect(getByRole('checkbox')).to.have.property('disabled', true);
});

it('the switch can be readonly', () => {
const { getByRole } = render(<Switch readOnly />);

expect(getByRole('checkbox')).to.have.property('readOnly', true);
});

it('the Checked state changes after change events', () => {
const { getByRole } = render(<Switch defaultChecked />);

// how a user would trigger it
act(() => {
getByRole('checkbox').click();
fireEvent.change(getByRole('checkbox'), { target: { checked: '' } });
});

expect(getByRole('checkbox')).to.have.property('checked', false);
});
});
Loading