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

Feature: APP-2700 - Switch component #59

Merged
merged 10 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Card` and `CardSummary` components
- Implement `Card`, `CardSummary`, and `Switch` components

### Changed

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"author": "Aragon Association",
"dependencies": {
"@radix-ui/react-progress": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"classnames": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export * from './illustrations';
export * from './input';
export * from './progress';
export * from './spinner';
export * from './switch';
export * from './tag';
1 change: 1 addition & 0 deletions src/components/switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Switch, type ISwitchProps } from './switch';
48 changes: 48 additions & 0 deletions src/components/switch/switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Switch, type ISwitchProps } from './switch';

const meta: Meta<typeof Switch> = {
title: 'components/Switch',
component: Switch,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?node-id=8850%3A12962&mode=dev',
},
},
};

type Story = StoryObj<typeof Switch>;

/**
* `Switch` used as an uncontrolled component
*/
export const Uncontrolled: Story = {
args: {
label: 'Show testnets',
name: 'testnet',
defaultChecked: true,
onCheckedChanged: undefined,
},
};

/**
* Controlled usage of the `Switch` component
*/
const ControlledComponent = (props: ISwitchProps) => {
const [checked, setChecked] = useState(false);

return <Switch checked={checked} onCheckedChanged={setChecked} {...props} />;
};

export const Controlled: Story = {
render: ({ onCheckedChanged, ...props }: ISwitchProps) => <ControlledComponent {...props} />,
args: {
label: 'Show testnets',
name: 'testnet',
},
};

export default meta;
61 changes: 61 additions & 0 deletions src/components/switch/switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Switch, type ISwitchProps } from './switch';

describe('<Switch /> component', () => {
const createTestComponent = (props?: Partial<ISwitchProps>) => {
const completeProps: ISwitchProps = { ...props };

return <Switch {...completeProps} />;
};

it('renders with default props', () => {
render(createTestComponent());
expect(screen.getByRole('switch')).toBeInTheDocument();
});

it('renders with custom props', () => {
const customProps = { checked: true, id: 'customId', disabled: true, label: 'customLabel', name: 'customName' };

render(createTestComponent(customProps));

const switchElement = screen.getByRole('switch');
expect(switchElement).toBeInTheDocument();
expect(switchElement).toHaveAttribute('data-state', 'checked');
expect(switchElement).toHaveAttribute('aria-checked', customProps.checked.toString());
expect(switchElement).toHaveAttribute('id', customProps.id);
});

it('associates label correctly', () => {
const label = 'customLabel';
render(createTestComponent({ label }));

expect(screen.getByLabelText(label)).toBeInTheDocument();
});

it('generates unique ID when no ID is provided', () => {
render(createTestComponent());
expect(screen.getByRole('switch')).toHaveAttribute('id');
});

it('invokes callback on state change and toggles state value', () => {
const mockCallback = jest.fn();
render(createTestComponent({ checked: true, onCheckedChanged: mockCallback }));

const switchElement = screen.getByRole('switch');
fireEvent.click(switchElement);

expect(mockCallback).toHaveBeenCalledWith(false);
});

it('renders as disabled when disabled prop is true', () => {
const mockCallback = jest.fn();
render(createTestComponent({ disabled: true, onCheckedChanged: mockCallback }));

const switchElement = screen.getByRole('switch');
fireEvent.click(switchElement);

expect(switchElement).toBeDisabled();
expect(switchElement).toHaveClass('disabled:cursor-not-allowed');
expect(mockCallback).not.toHaveBeenCalled();
});
});
102 changes: 102 additions & 0 deletions src/components/switch/switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as RadixSwitch from '@radix-ui/react-switch';
import classNames from 'classnames';
import { useId, type HtmlHTMLAttributes } from 'react';

const rootClassNames = classNames(
'group peer w-10 cursor-default rounded-[40px] border border-neutral-200 bg-neutral-0 p-1', // Default
'data-[state=checked]:border-primary-400 data-[state=checked]:shadow-primary-md', // State is checked
'focus:outline-none focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', // Focus
'disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:data-[state=checked]:border-neutral-200 disabled:data-[state=checked]:shadow-none', // Disabled
);

const thumbClassNames = classNames(
'block h-4 w-4 rounded-full bg-neutral-300 transition-transform duration-100 will-change-transform', // Default
'data-[state=checked]:translate-x-[14px] data-[state=checked]:bg-primary-400', // State is checked
'group-disabled:bg-neutral-200 group-disabled:data-[state=checked]:bg-neutral-300', // Disabled
);

// using `peer` since the parent div is not focusable nor able to be disabled
const labelClassNames = classNames(
'text-sm/tight font-semibold text-neutral-600', // Default
'peer-disabled:text-neutral-300 peer-disabled:peer-data-[state=checked]:text-neutral-600', // Disabled
);

export interface ISwitchProps extends HtmlHTMLAttributes<HTMLDivElement> {
/**
* Indicates whether the switch is checked
*/
checked?: boolean;
/**
* CSS class name
*/
className?: string;
/**
* The default checked state of the switch
* @default false
*/
defaultChecked?: boolean;
/**
* Indicates whether the switch is disabled
* @default false
*/
disabled?: boolean;
/**
* The ID of the switch
*/
id?: string;
/**
* The label of the switch
*/
label?: string;
/**
* The name of the switch
*/
name?: string;
/**
* Event handler for when the checked state changes
* @param checked - The new checked state
*/
onCheckedChanged?: (checked: boolean) => void;
}

/**
* Switch component
*/
export const Switch: React.FC<ISwitchProps> = (props) => {
const {
checked,
className,
defaultChecked = false,
disabled = false,
id: propId,
label,
name,
onCheckedChanged,
...otherProps
} = props;

// use randomly generated id when non provided
const internalId = useId();
const id = propId ?? internalId;

const switchProps = {
id,
name,
checked,
disabled,
defaultChecked,
};

return (
<div className={classNames('inline-flex items-center gap-x-2 md:gap-x-3', className)} {...otherProps}>
Fabricevladimir marked this conversation as resolved.
Show resolved Hide resolved
<RadixSwitch.Root {...switchProps} className={rootClassNames} onCheckedChange={onCheckedChanged}>
<RadixSwitch.Thumb className={thumbClassNames} />
</RadixSwitch.Root>
{label && (
<label htmlFor={id} className={labelClassNames}>
<span>{label}</span>
</label>
)}
</div>
);
};
14 changes: 14 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1872,6 +1872,20 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"

"@radix-ui/react-switch@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e"
integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"

"@radix-ui/[email protected]":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz#f5b5c8c477831b013bec3580c55e20a68179d6ec"
Expand Down