-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ToggleGroup): ✨ New
ToggleGroup
component (#813)
Co-authored-by: Michael Marszalek <[email protected]> Co-authored-by: Michael Marszalek <[email protected]>
- Loading branch information
Showing
15 changed files
with
832 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
13
packages/react/src/components/ToggleGroup/ToggleGroup.module.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
117
packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
131
packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}, | ||
); |
Oops, something went wrong.