Skip to content

Commit

Permalink
Feature: APP-2700 - Switch component (#59)
Browse files Browse the repository at this point in the history
Co-authored-by: Ruggero <[email protected]>
  • Loading branch information
Fabricevladimir and cgero-eth authored Jan 11, 2024
1 parent 4a3931f commit ef99408
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 1 deletion.
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}>
<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

0 comments on commit ef99408

Please sign in to comment.