Skip to content

Commit

Permalink
feat: APP-2694 - Implement Toggle and ToggleGroups components (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
cgero-eth authored Jan 12, 2024
1 parent ef99408 commit ca6cb7b
Show file tree
Hide file tree
Showing 18 changed files with 366 additions and 3 deletions.
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} />,
};

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;
/**
* 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

0 comments on commit ca6cb7b

Please sign in to comment.