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

feat: APP-2694 - Implement Toggle and ToggleGroups components #58

Merged
merged 12 commits into from
Jan 12, 2024
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`, `CardSummary`, and `Switch` components
- Implement `Card`, `CardSummary`, `Switch`, `Toggle` and `ToggleGroup` components

### Changed

Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const config = {
testEnvironment: 'jsdom',
collectCoverageFrom: ['./src/**/*.{ts,tsx}'],
coveragePathIgnorePatterns: ['.d.ts', '.api.ts', 'index.ts', '.stories.tsx'],
coveragePathIgnorePatterns: ['.d.ts', '.api.ts', 'index.ts', '.stories.tsx', './src/test/*'],
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
transform: {
'^.+\\.svg$': '<rootDir>/src/test/svgTransform.js',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dependencies": {
"@radix-ui/react-progress": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^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 @@ -10,3 +10,4 @@ export * from './progress';
export * from './spinner';
export * from './switch';
export * from './tag';
export * from './toggles';
2 changes: 2 additions & 0 deletions src/components/toggles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './toggle';
export * from './toggleGroup';
1 change: 1 addition & 0 deletions src/components/toggles/toggle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Toggle, type IToggleProps } from './toggle';
71 changes: 71 additions & 0 deletions src/components/toggles/toggle/toggle.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ToggleGroup } from '../toggleGroup';
import { Toggle, type IToggleProps } from './toggle';

const meta: Meta<typeof Toggle> = {
title: 'components/Toggles/Toggle',
component: Toggle,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=9778-14&mode=dev',
},
},
};

type Story = StoryObj<typeof Toggle>;

/**
* Default usage example of the Toggle component.
*/
export const Default: Story = {
render: (props) => (
<ToggleGroup isMultiSelect={false}>
<Toggle {...props} />
</ToggleGroup>
),
args: {
value: 'value',
label: 'Label',
},
};

const ControllerComponent = (props: IToggleProps) => {
const [value, setValue] = useState<string>();

return (
<ToggleGroup isMultiSelect={false} value={value} onChange={setValue}>
<Toggle {...props} />
</ToggleGroup>
);
};

/**
* Controlled usage example of the Toggle component.
*/
export const Controlled: Story = {
render: (props) => <ControllerComponent {...props} />,
args: {
value: 'value',
label: 'Label',
},
};

/**
* Disabled Toggle component.
*/
export const Disabled: Story = {
render: (props) => (
<ToggleGroup isMultiSelect={false}>
<Toggle {...props} />
</ToggleGroup>
),
args: {
disabled: true,
label: 'Disabled',
},
};

export default meta;
37 changes: 37 additions & 0 deletions src/components/toggles/toggle/toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ToggleGroup } from '../toggleGroup';
import { Toggle, type IToggleProps } from './toggle';

describe('<Toggle /> component', () => {
const createTestComponent = (props?: Partial<IToggleProps>) => {
const completeProps: IToggleProps = {
label: 'label',
value: 'value',
...props,
};

return (
<ToggleGroup isMultiSelect={false}>
<Toggle {...completeProps} />
</ToggleGroup>
);
};

it('renders a toggle with the specified label', () => {
const label = 'Toggle Label';
render(createTestComponent({ label }));
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
});

it('renders the toggle as disabled when the disabled prop is set to true', () => {
const disabled = true;
render(createTestComponent({ disabled }));
expect(screen.getByRole('radio')).toBeDisabled();
});

it('renders the toggle as active when clicked', () => {
render(createTestComponent());
fireEvent.click(screen.getByRole('radio'));
expect(screen.getByRole('radio').className).toContain('text-neutral-800');
});
});
39 changes: 39 additions & 0 deletions src/components/toggles/toggle/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ToggleGroupItem as RadixToggle } from '@radix-ui/react-toggle-group';
import classNames from 'classnames';
import type { ComponentProps } from 'react';

export interface IToggleProps extends Omit<ComponentProps<'button'>, 'ref'> {
/**
* Value of the toggle.
*/
value: string;
/**
* Label of the toggle.
*/
label: string;
}

/**
* The Toggle component is a button that handles the "on" and "off" states.
*
* **NOTE**: The component must be used inside a `<ToggleGroup />` component in order to work properly.
*/
export const Toggle: React.FC<IToggleProps> = (props) => {
const { className, label, value, disabled, ...otherProps } = props;

const toggleClasses = classNames(
'flex h-10 items-center rounded-[40px] border border-neutral-100 px-4', // Default
'focus:outline-none focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', // Focus state
'hover:enabled:border-neutral-200 hover:enabled:shadow-primary-md', // Hover state
'data-[state=off]:enabled:bg-neutral-0 data-[state=off]:enabled:text-neutral-600', // Default state
'data-[state=on]:enabled:bg-neutral-100 data-[state=on]:enabled:text-neutral-800', // Active state
'disabled:bg-neutral-100 disabled:text-neutral-300', // Disabled state
className,
);

return (
<RadixToggle className={toggleClasses} disabled={disabled} value={value} {...otherProps}>
<p className="text-sm font-semibold leading-normal md:text-base">{label}</p>
</RadixToggle>
);
};
1 change: 1 addition & 0 deletions src/components/toggles/toggleGroup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ToggleGroup, type IToggleGroupProps } from './toggleGroup';
72 changes: 72 additions & 0 deletions src/components/toggles/toggleGroup/toggleGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Toggle } from '../toggle';
import { ToggleGroup, type IToggleGroupProps } from './toggleGroup';

const meta: Meta<typeof ToggleGroup> = {
title: 'components/Toggles/ToggleGroup',
component: ToggleGroup,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=11857-23553&mode=dev',
},
},
};

type Story = StoryObj<typeof ToggleGroup>;

/**
* Default usage example of the ToggleGroup component.
*/
export const Default: Story = {
render: (props) => (
<ToggleGroup {...props}>
<Toggle value="multisig" label="Multisig" />
<Toggle value="token-based" label="Token Based" />
</ToggleGroup>
),
};

const ControlledComponent = (props: Omit<IToggleGroupProps, 'value' | 'onChange' | 'isMultiSelect'>) => {
const [value, setValue] = useState<string>();

return (
<ToggleGroup isMultiSelect={false} value={value} onChange={setValue} {...props}>
<Toggle value="ethereum" label="Ethereum" />
<Toggle value="polygon" label="Polygon" />
<Toggle value="base" label="Base" />
<Toggle value="arbitrum" label="Arbitrum" />
<Toggle value="bsc" label="Binance Smart Chain" />
</ToggleGroup>
);
};

/**
* Controlled usage example of the ToggleGroup component.
*/
export const Controlled: Story = {
render: ({ value, onChange, isMultiSelect, ...props }) => <ControlledComponent {...props} />,
Fabricevladimir marked this conversation as resolved.
Show resolved Hide resolved
};

const MultiSelectComponent = (props: Omit<IToggleGroupProps, 'value' | 'onChange' | 'isMultiSelect'>) => {
const [value, setValue] = useState<string[]>();

return (
<ToggleGroup isMultiSelect={true} value={value} onChange={setValue} {...props}>
<Toggle value="all" label="All DAOs" />
<Toggle value="member" label="Member" />
<Toggle value="following" label="Following" disabled={true} />
</ToggleGroup>
);
};

/**
* ToggleGroup component used with multiple selection.
*/
export const MultiSelect: Story = {
render: ({ value, onChange, isMultiSelect, ...props }) => <MultiSelectComponent {...props} />,
};

export default meta;
60 changes: 60 additions & 0 deletions src/components/toggles/toggleGroup/toggleGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Toggle } from '../toggle';
import { ToggleGroup, type IToggleGroupBaseProps, type IToggleGroupProps } from './toggleGroup';

describe('<ToggleGroup /> component', () => {
const createTestComponent = (props: Partial<IToggleGroupProps> = {}) => {
if (props?.isMultiSelect) {
return <ToggleGroup isMultiSelect={true} {...props} />;
}

const { isMultiSelect, ...otherProps } = props as IToggleGroupBaseProps<false>;

return <ToggleGroup isMultiSelect={false} {...otherProps} />;
};

it('renders the children components', () => {
const children = [
<Toggle key="first" value="first" label="First" />,
<Toggle key="second" value="second" label="Second" />,
];
render(createTestComponent({ children }));
expect(screen.getAllByRole('radio')).toHaveLength(children.length);
});

it('correctly updates the active value on toggle click', () => {
const onChange = jest.fn();
const value = 'test';
const children = [<Toggle key={value} value={value} label={value} />];
const { rerender } = render(createTestComponent({ onChange, children }));

fireEvent.click(screen.getByRole('radio'));
expect(onChange).toHaveBeenCalledWith(value);

rerender(createTestComponent({ value, onChange, children }));

fireEvent.click(screen.getByRole('radio'));
expect(onChange).toHaveBeenCalledWith('');
});

it('correctly updates the active values on toggle click on multi-select variant', () => {
const onChange = jest.fn();
const isMultiSelect = true;
const firstValue = 'first';
const secondValue = 'second';
const children = [
<Toggle key={firstValue} value={firstValue} label={firstValue} />,
<Toggle key={secondValue} value={secondValue} label={secondValue} />,
];
const { rerender } = render(createTestComponent({ onChange, children, isMultiSelect }));

fireEvent.click(screen.getByRole('button', { name: firstValue }));
const newValue = [firstValue];
expect(onChange).toHaveBeenCalledWith(newValue);

rerender(createTestComponent({ value: newValue, onChange, children, isMultiSelect }));

fireEvent.click(screen.getByRole('button', { name: secondValue }));
expect(onChange).toHaveBeenCalledWith([...newValue, secondValue]);
});
});
44 changes: 44 additions & 0 deletions src/components/toggles/toggleGroup/toggleGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ToggleGroup as RadixToggleGroup } from '@radix-ui/react-toggle-group';
import classNames from 'classnames';
import type { ComponentProps } from 'react';

export type ToggleGroupValue<TMulti extends boolean> = TMulti extends true ? string[] | undefined : string | undefined;

export interface IToggleGroupBaseProps<TMulti extends boolean>
extends Omit<ComponentProps<'div'>, 'value' | 'onChange' | 'defaultValue' | 'ref' | 'dir'> {
/**
* Allows multiple toggles to be selected at the same time when set to true.
*/
isMultiSelect: TMulti;
cgero-eth marked this conversation as resolved.
Show resolved Hide resolved
/**
* Current value of the toggle selection.
*/
value?: ToggleGroupValue<TMulti>;
/**
* Callback called on toggle selection change.
*/
onChange?: (value: ToggleGroupValue<TMulti>) => void;
}

export type IToggleGroupProps = IToggleGroupBaseProps<true> | IToggleGroupBaseProps<false>;

export const ToggleGroup = (props: IToggleGroupProps) => {
const { value, onChange, isMultiSelect, className, ...otherProps } = props;
const classes = classNames('flex flex-row flex-wrap gap-2 md:gap-3', className);

if (isMultiSelect === true) {
return (
<RadixToggleGroup
type="multiple"
className={classes}
value={value}
onValueChange={onChange}
{...otherProps}
/>
);
}

return (
<RadixToggleGroup type="single" className={classes} value={value} onValueChange={onChange} {...otherProps} />
);
};
1 change: 1 addition & 0 deletions src/test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './utils';
4 changes: 4 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import { testLogger } from './utils';

// Setup test logger
testLogger.setup();
1 change: 1 addition & 0 deletions src/test/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { testLogger } from './testLogger';
Loading