mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-28 14:01:32 -05:00
feat(UI): implement ToggleButton component (#48567)
This commit is contained in:
@@ -3,3 +3,4 @@ export { Button } from './button';
|
||||
export { Alert } from './alert';
|
||||
export { Image } from './image';
|
||||
export { Table } from './table';
|
||||
export { ToggleButton } from './toggle-button';
|
||||
|
||||
2
tools/ui-components/src/toggle-button/index.ts
Normal file
2
tools/ui-components/src/toggle-button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ToggleButton } from './toggle-button';
|
||||
export type { ToggleButtonProps } from './types';
|
||||
125
tools/ui-components/src/toggle-button/toggle-button.stories.tsx
Normal file
125
tools/ui-components/src/toggle-button/toggle-button.stories.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { ToggleButton, ToggleButtonProps } from '.';
|
||||
|
||||
const story = {
|
||||
title: 'Example/ToggleButton',
|
||||
component: ToggleButton,
|
||||
parameters: {
|
||||
controls: {
|
||||
include: [
|
||||
'children',
|
||||
'bsStyle',
|
||||
'bsSize',
|
||||
'disabled',
|
||||
'checked',
|
||||
'onChange',
|
||||
'value',
|
||||
'name'
|
||||
]
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
bsStyle: {
|
||||
options: ['primary']
|
||||
},
|
||||
bsSize: {
|
||||
options: ['small', 'medium', 'large']
|
||||
},
|
||||
disabled: {
|
||||
options: [true, false],
|
||||
control: { type: 'radio' }
|
||||
},
|
||||
checked: {
|
||||
options: [true, false],
|
||||
control: { type: 'radio' }
|
||||
},
|
||||
onChange: {
|
||||
action: 'changed'
|
||||
},
|
||||
value: {
|
||||
type: { name: 'string' }
|
||||
},
|
||||
name: {
|
||||
type: { name: 'string' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<ToggleButtonProps> = args => {
|
||||
return <ToggleButton {...args} />;
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
children: 'Off'
|
||||
};
|
||||
|
||||
export const Checked = Template.bind({});
|
||||
Checked.args = {
|
||||
checked: true,
|
||||
children: 'On',
|
||||
value: 'Value'
|
||||
};
|
||||
|
||||
export const Large = Template.bind({});
|
||||
Large.args = {
|
||||
bsSize: 'large',
|
||||
children: 'Off'
|
||||
};
|
||||
|
||||
export const Medium = Template.bind({});
|
||||
Medium.args = {
|
||||
bsSize: 'medium',
|
||||
children: 'Off'
|
||||
};
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
Disabled.args = {
|
||||
children: 'Off',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
export const RadioChecked = Template.bind({});
|
||||
RadioChecked.args = {
|
||||
type: 'radio',
|
||||
children: 'On',
|
||||
value: 'radio',
|
||||
name: 'radio',
|
||||
checked: true
|
||||
};
|
||||
|
||||
export const RadioUnchecked = Template.bind({});
|
||||
RadioUnchecked.args = {
|
||||
type: 'radio',
|
||||
children: 'Off',
|
||||
value: 'radio',
|
||||
name: 'radio'
|
||||
};
|
||||
|
||||
export const InsideToggleGroup = (): JSX.Element => {
|
||||
const [checked, setChecked] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleButton
|
||||
checked={checked}
|
||||
onChange={checked => {
|
||||
setChecked(checked);
|
||||
}}
|
||||
>
|
||||
On
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
checked={!checked}
|
||||
onChange={checked => {
|
||||
setChecked(!checked);
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</ToggleButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default story;
|
||||
78
tools/ui-components/src/toggle-button/toggle-button.test.tsx
Normal file
78
tools/ui-components/src/toggle-button/toggle-button.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ToggleButton } from '.';
|
||||
|
||||
describe('<ToggleButton />', () => {
|
||||
it('should render the toggle button and text', () => {
|
||||
render(<ToggleButton>On</ToggleButton>);
|
||||
|
||||
expect(screen.getByRole('button', { name: /on/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ToggleButton onChange={onChange}>On</ToggleButton>);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: /on/i }));
|
||||
|
||||
expect(onChange).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should be checked if checked prop is true', () => {
|
||||
render(
|
||||
<ToggleButton checked={true} type='radio'>
|
||||
On
|
||||
</ToggleButton>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio')).toBeChecked();
|
||||
});
|
||||
|
||||
it('should be unchecked if checked prop is false', () => {
|
||||
render(
|
||||
<ToggleButton checked={false} type='radio'>
|
||||
On
|
||||
</ToggleButton>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should be aria-disabled if disabled prop is true', () => {
|
||||
render(<ToggleButton disabled={true}>On</ToggleButton>);
|
||||
|
||||
expect(screen.getByRole('button', { name: /on/i })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not trigger onChange if disabled prop is true', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ToggleButton disabled={true} onChange={onChange}>
|
||||
On
|
||||
</ToggleButton>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: /on/i }));
|
||||
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should have value property if radio', () => {
|
||||
render(
|
||||
<form aria-label='form'>
|
||||
<ToggleButton checked={true} type='radio' value='value' name='radio'>
|
||||
On
|
||||
</ToggleButton>
|
||||
</form>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('form')).toHaveFormValues({
|
||||
radio: 'value'
|
||||
});
|
||||
});
|
||||
});
|
||||
119
tools/ui-components/src/toggle-button/toggle-button.tsx
Normal file
119
tools/ui-components/src/toggle-button/toggle-button.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { ButtonSize, ToggleButtonProps } from './types';
|
||||
|
||||
const defaultClassNames = [
|
||||
'relative',
|
||||
'border-3',
|
||||
'text-center',
|
||||
'inline-block',
|
||||
'cursor-pointer',
|
||||
'border-foreground-secondary',
|
||||
'focus:outline-none', // Hide the default browser outline
|
||||
'focus:ring',
|
||||
'focus:ring-focus-outline-color',
|
||||
'focus-within:ring',
|
||||
'focus-within:ring-focus-outline-color',
|
||||
'aria-disabled:cursor-not-allowed',
|
||||
'aria-disabled:opacity-50',
|
||||
'ml-[-3px]',
|
||||
'first:ml-0'
|
||||
];
|
||||
|
||||
const computeClassNames = ({
|
||||
bsSize,
|
||||
checked,
|
||||
disabled
|
||||
}: {
|
||||
bsSize: ButtonSize;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const classNames = [
|
||||
...defaultClassNames,
|
||||
...(checked
|
||||
? ['cursor-default', 'bg-foreground-primary', 'text-background-primary']
|
||||
: ['bg-background-quaternary', 'text-foreground-secondary']),
|
||||
...(disabled
|
||||
? ['active:before:hidden']
|
||||
: [
|
||||
'active:before:w-full',
|
||||
'active:before:h-full',
|
||||
'active:before:absolute',
|
||||
'active:before:inset-0',
|
||||
'active:before:border-3',
|
||||
'active:before:border-transparent',
|
||||
'active:before:bg-gray-900',
|
||||
'active:before:opacity-20',
|
||||
'dark:hover:bg-background-primary',
|
||||
'dark:hover:text-foreground-primary',
|
||||
...(checked
|
||||
? [
|
||||
'hover:bg-background-quaternary',
|
||||
'hover:text-foreground-secondary'
|
||||
]
|
||||
: ['hover:bg-foreground-primary', 'hover:text-background-primary'])
|
||||
])
|
||||
];
|
||||
|
||||
switch (bsSize) {
|
||||
case 'large':
|
||||
classNames.push('px-8 py-2.5 text-lg');
|
||||
break;
|
||||
case 'medium':
|
||||
classNames.push('px-6 py-1.5 text-md');
|
||||
break;
|
||||
// default size is 'small'
|
||||
default:
|
||||
classNames.push('px-5 py-1 text-sm');
|
||||
}
|
||||
|
||||
return classNames.join(' ');
|
||||
};
|
||||
|
||||
export const ToggleButton = ({
|
||||
bsSize = 'small',
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
checked,
|
||||
onChange,
|
||||
value,
|
||||
name
|
||||
}: ToggleButtonProps): JSX.Element => {
|
||||
const classNames = computeClassNames({ bsSize, disabled, checked });
|
||||
|
||||
const handleChange = () => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (type === 'radio') {
|
||||
return (
|
||||
<label htmlFor='toggle-btn-radio' className={classNames}>
|
||||
<input
|
||||
type='radio'
|
||||
id='toggle-btn-radio'
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
checked={checked}
|
||||
aria-disabled={disabled}
|
||||
className='absolute h-0 w-0 opacity-0'
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={checked}
|
||||
aria-disabled={disabled}
|
||||
className={classNames}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
16
tools/ui-components/src/toggle-button/types.ts
Normal file
16
tools/ui-components/src/toggle-button/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ButtonStyle = 'primary' | 'danger';
|
||||
|
||||
export type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface ToggleButtonProps {
|
||||
children: React.ReactNode;
|
||||
bsSize?: ButtonSize;
|
||||
bsStyle?: ButtonStyle;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
className?: string;
|
||||
value?: string;
|
||||
name?: string;
|
||||
type?: 'button' | 'radio';
|
||||
}
|
||||
Reference in New Issue
Block a user