Skip to content

Commit

Permalink
feat(ToggleGroup): ✨ New ToggleGroup component (#813)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Marszalek <[email protected]>
Co-authored-by: Michael Marszalek <[email protected]>
  • Loading branch information
3 people authored and Barsnes committed Sep 28, 2023
1 parent aac90c2 commit a30d0c9
Show file tree
Hide file tree
Showing 15 changed files with 832 additions and 1 deletion.
2 changes: 1 addition & 1 deletion packages/Overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ V1 er klar når følgende komponenter er markert som "✅ Felles":
| [Tag](/docs/felles-tag--docs) | ✅ Felles | [Figma - Tag](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=10185%3A59053&t=7Q2N4sUdQGhFZrPh-1) | [Github - Tag](https://github.com/digdir/designsystem/issues/322) |
| [Textarea](/docs/felles-textarea--docs) | ✅ Felles | [Figma - Text area](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A21873&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textarea](https://github.com/digdir/designsystem/issues/323) |
| [Textfield](/docs/felles-textfield--docs) | ✅ Felles | [Figma - Text Field](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A22228&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textfield](https://github.com/digdir/designsystem/issues/92) |
| [ToggleGroup](/docs/altinn-togglebuttongroup--docs) | 🔵 Altinn | Figma - Toggle Group | [Github - ToggleGroup](https://github.com/digdir/designsystem/issues/304) |
| [ToggleGroup](/docs/felles-togglegroup--docs) | ✅ Felles | Figma - Toggle Group | [Github - ToggleGroup](https://github.com/digdir/designsystem/issues/304) |
| Tooltip | 🚸 Ikke påbegynt | Figma - Tooltip | [Github - Tooltip](https://github.com/digdir/designsystem/issues/93) |
| [Typography](/docs/felles-typography--docs) | ✅ Felles | [Figma - Typography](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=9219%3A49405&t=7Q2N4sUdQGhFZrPh-1) | [Github - Typography](https://github.com/digdir/designsystem/issues/324) |

Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/components/ToggleGroup/ToggleGroup.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks';
import { Information } from '../../../../../docs-components';
import * as ToggleGroupStories from './ToggleGroup.stories';

<Meta of={ToggleGroupStories} />

# ToggleGroup

Description of the ToggleGroup component.

<Primary />
<Controls />

## Bruk

<Information text='token' />

```tsx
import '@digdir/design-system-tokens/brand/altinn/tokens.css'; // Importeres kun en gang i appen din.
import { ToggleGroup } from '@digdir/design-system-react';

<ToggleGroup defaultValue="value1">
<ToggleGroup value="value1">Option 1</ToggleGroup.Item>
<ToggleGroup value="value2">Option 2</ToggleGroup.Item>
<ToggleGroup value="value3">Option 3</ToggleGroup.Item>
</ToggleGroup>;
```

## Only Icons

<Canvas of={ToggleGroupStories.OnlyIcons} />

## Controlled

<Canvas of={ToggleGroupStories.Controlled} />
13 changes: 13 additions & 0 deletions packages/react/src/components/ToggleGroup/ToggleGroup.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.toggleGroupContainer {
background-color: var(--fds-semantic-background-default);
border: var(--fds-semantic-border-neutral-default) solid var(--fds-border_width-default);
border-radius: var(--fds-border_radius-medium);
}

.groupContent {
display: inline-grid;
gap: var(--fds-spacing-1);
grid-auto-columns: 1fr;
grid-auto-flow: column;
padding: var(--fds-spacing-1);
}
117 changes: 117 additions & 0 deletions packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import * as icons from '@navikt/aksel-icons';

import { Button } from '../Button';

import { ToggleGroup } from '.';

const icon = (
<svg
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0Zm0 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm5.047 5.671 1.399 1.43-8.728 8.398L6 14.02l1.395-1.434 2.319 2.118 7.333-7.032Z'
fill='currentColor'
/>
</svg>
);

const AkselIcon = icons.AirplaneFillIcon;
const AkselIcon2 = icons.NewspaperFillIcon;
const AkselIcon3 = icons.BrailleIcon;
const AkselIcon4 = icons.BackpackFillIcon;

export default {
title: 'Felles/ToggleGroup',
component: ToggleGroup,
} as Meta;

export const Preview: StoryFn<typeof ToggleGroup> = (args) => {
return (
<ToggleGroup {...args}>
<ToggleGroup.Item>Peanut</ToggleGroup.Item>
<ToggleGroup.Item>Walnut</ToggleGroup.Item>
<ToggleGroup.Item>Pistachio 🤤</ToggleGroup.Item>
</ToggleGroup>
);
};

Preview.args = {
defaultValue: 'Peanut',
size: 'medium',
name: 'toggle-group-nuts',
};

export const OnlyIcons: StoryFn<typeof ToggleGroup> = () => {
const handleChange = (value: string) => {
console.log(value);
};

return (
<ToggleGroup
defaultValue={'option-1'}
onChange={handleChange}
>
<ToggleGroup.Item
value={'option-1'}
icon={<AkselIcon3 />}
/>
<ToggleGroup.Item
value={'option-2'}
icon={<AkselIcon2 />}
/>
<ToggleGroup.Item
value={'option-3'}
icon={<AkselIcon4 />}
/>
</ToggleGroup>
);
};

export const Controlled: StoryFn<typeof ToggleGroup> = () => {
const [value, setValue] = useState<string>('peanut');
return (
<>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
size='small'
onClick={() => setValue('peanut')}
>
Select Peanut
</Button>
</div>
<br />
<ToggleGroup
value={value}
size='medium'
onChange={setValue}
>
<ToggleGroup.Item
value='pistachio'
icon={<AkselIcon />}
>
Pistachio
</ToggleGroup.Item>
<ToggleGroup.Item
value='peanut'
icon={icon}
>
Peanut
</ToggleGroup.Item>
<ToggleGroup.Item
value='walnut'
icon={<AkselIcon2 />}
>
Walnut
</ToggleGroup.Item>
</ToggleGroup>
<br />
<span>You have chosen: {value}</span>
</>
);
};
131 changes: 131 additions & 0 deletions packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { ToggleGroup } from '.';

const user = userEvent.setup();

describe('ToggleGroup', () => {
test('has generated name for ToggleGroupItem children', () => {
render(
<ToggleGroup>
<ToggleGroup.Item value='test'>test</ToggleGroup.Item>
</ToggleGroup>,
);

const item = screen.getByRole('radio');
expect(item).toHaveAttribute('name');
});

test('has passed name to ToggleGroupItem children', (): void => {
render(
<ToggleGroup name='my name'>
<ToggleGroup.Item value='test'>test</ToggleGroup.Item>
</ToggleGroup>,
);

const item = screen.getByRole<HTMLButtonElement>('radio');
expect(item.name).toEqual('my name');
});
test('has passed size to ToggleGroupItem children', (): void => {
render(
<ToggleGroup size='medium'>
<ToggleGroup.Item value='test'>test</ToggleGroup.Item>
</ToggleGroup>,
);

const item = screen.getByRole<HTMLButtonElement>('radio');
expect(item).toHaveClass('medium');
});
test('can navigate with tab and arrow keys', async () => {
render(
<ToggleGroup>
<ToggleGroup.Item value='test'>test</ToggleGroup.Item>
<ToggleGroup.Item value='test2'>test2</ToggleGroup.Item>
<ToggleGroup.Item value='test3'>test3</ToggleGroup.Item>
</ToggleGroup>,
);

const item1 = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test',
});
const item2 = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test2',
});
const item3 = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test3',
});
await user.tab();
expect(item1).toHaveFocus();
await user.type(item1, '{arrowright}');
expect(item2).toHaveFocus();
await user.type(item2, '{arrowright}');
expect(item3).toHaveFocus();
await user.type(item3, '{arrowleft}');
expect(item2).toHaveFocus();
});
test('has correct ToggleGroupItem defaultChecked & checked when defaultValue is used', () => {
render(
<ToggleGroup defaultValue='test2'>
<ToggleGroup.Item value='test1'>test1</ToggleGroup.Item>
<ToggleGroup.Item value='test2'>test2</ToggleGroup.Item>
<ToggleGroup.Item value='test3'>test3</ToggleGroup.Item>
</ToggleGroup>,
);

const item = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test2',
});
expect(item).toHaveAttribute('aria-checked', 'true');
});
test('has passed clicked ToggleGroupItem element to onChange', async () => {
let onChangeValue = '';

render(
<ToggleGroup onChange={(value) => (onChangeValue = value)}>
<ToggleGroup.Item value='test1'>test1</ToggleGroup.Item>
<ToggleGroup.Item value='test2'>test2</ToggleGroup.Item>
</ToggleGroup>,
);

const item = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test2',
});

expect(item).toHaveAttribute('aria-checked', 'false');

await user.click(item);

expect(onChangeValue).toEqual('test2');
expect(item).toHaveAttribute('aria-checked', 'true');
});
test('has passed clicked ToggleGroupItem element to onChange when defaultValue is used', async () => {
let onChangeValue = '';

render(
<ToggleGroup
defaultValue='test1'
onChange={(value) => (onChangeValue = value)}
>
<ToggleGroup.Item value='test1'>test1</ToggleGroup.Item>
<ToggleGroup.Item value='test2'>test2</ToggleGroup.Item>
</ToggleGroup>,
);

const item1 = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test1',
});
const item2 = screen.getByRole<HTMLButtonElement>('radio', {
name: 'test2',
});

expect(item1).toHaveAttribute('aria-checked', 'true');
expect(item2).toHaveAttribute('aria-checked', 'false');

await user.click(item2);

expect(onChangeValue).toEqual('test2');
expect(item2).toHaveAttribute('aria-checked', 'true');
});
});
89 changes: 89 additions & 0 deletions packages/react/src/components/ToggleGroup/ToggleGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { HTMLAttributes } from 'react';
import React, { createContext, forwardRef, useId, useState } from 'react';
import cn from 'classnames';

import { RovingTabindexRoot } from '../../utility-components/RovingTabIndex';

import classes from './ToggleGroup.module.css';

export type ToggleGroupContextProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
name?: string;
size?: 'small' | 'medium' | 'large';
};

export const ToggleGroupContext = createContext<ToggleGroupContextProps>({});

export type ToggleGroupProps = {
/** Controlled state for `ToggleGroup` component. */
value?: string;
/** Default value. */
defaultValue?: string;
/** Callback with selected `ToggleGroupItem` `value` */
onChange?: (value: string) => void;
/** Form element name */
name?: string;
/** Changes items size and paddings */
size?: 'small' | 'medium' | 'large';
} & Omit<HTMLAttributes<HTMLDivElement>, 'value' | 'onChange'>;

/** `ToggleGroup` component.
* @example
* ```tsx
* <ToggleGroup onChange={(value) => console.log(value)}>
* <ToggleGroup.Item value='1'>Toggle 1</ToggleGroup.Item>
* <ToggleGroup.Item value='2'>Toggle 2</ToggleGroup.Item>
* <ToggleGroup.Item value='3'>Toggle 3</ToggleGroup.Item>
* </ToggleGroup>
* ```
*/
export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
(
{ children, value, defaultValue, onChange, size = 'medium', name, ...rest },
ref,
) => {
const nameId = useId();
const isControlled = value !== undefined;
const [uncontrolledValue, setUncontrolledValue] = useState<
string | undefined
>(defaultValue);

let onValueChange = onChange;
if (!isControlled) {
onValueChange = (newValue: string) => {
setUncontrolledValue(newValue);
onChange?.(newValue);
};
value = uncontrolledValue;
}

return (
<div
{...rest}
className={cn(classes.toggleGroupContainer, rest.className)}
ref={ref}
>
<ToggleGroupContext.Provider
value={{
value,
defaultValue,
name: name ?? `togglegroup-name-${nameId}`,
onChange: onValueChange,
size,
}}
>
<RovingTabindexRoot
as='div'
valueId={value}
className={classes.groupContent}
role='radiogroup'
>
{children}
</RovingTabindexRoot>
</ToggleGroupContext.Provider>
</div>
);
},
);
Loading

0 comments on commit a30d0c9

Please sign in to comment.