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(Drawer): new Drawer component #2203

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"dependencies": {
"@floating-ui/react": "0.26.12",
"@navikt/aksel-icons": "^5.12.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-virtual": "^3.2.0"
},
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/components/Drawer/Drawer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Meta, Canvas, Controls, Primary } from '@storybook/blocks';

import * as DrawerStories from './Drawer.stories';

<Meta of={DrawerStories} />

# Drawer

Med `Drawer` kan du vise tilleggsinformasjon eller kontroller uten å forlate den nåværende siden. Den er ideell for å presentere sekundært innhold som ikke trenger å være synlig hele tiden.

**Vær oppmerksom på:**

- Drawer bør ikke inneholde kritisk informasjon som brukeren må se umiddelbart.
- Innholdet i Drawer bør være fokusert og relevant for den aktuelle konteksten.

<br />

<Primary />
<Controls />

## Bruk

```tsx
import { Drawer } from '@digdir/designsystemet-react';

<Drawer
trigger={<Button>Åpne Drawer</Button>}
title='Drawer Tittel'
>
<p>Drawer innhold</p>
</Drawer>;
```
123 changes: 123 additions & 0 deletions packages/react/src/components/Drawer/Drawer.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
.overlay {
background-color: rgba(0 0 0 0.5);
position: fixed;
inset: 0;
animation: overlay-show 150ms cubic-bezier(0.16, 1, 0.3, 1);
}

.content {
background-color: white;
box-shadow:
hsl(206deg 22% 7% / 0.35) 0 10px 38px -10px,
hsl(206deg 22% 7% / 0.2) 0 10px 20px -15px;
position: fixed;
top: 0;
bottom: 0;
width: 90%;
max-width: 450px;
padding: 25px;
animation-duration: 150ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

.content:focus {
outline: none;
}

.right {
right: 0;
animation-name: content-show-right;
}

.left {
left: 0;
animation-name: content-show-left;
}

.top {
top: 0;
left: 0;
right: 0;
bottom: auto;
width: 100%;
max-width: 100%;
height: 50%;
max-height: 300px;
animation-name: content-show-top;
}

.closeButton {
position: fixed;
right: 0;
margin-right: var(--fds-spacing-4);
}

.bottom {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-width: 100%;
height: 50%;
max-height: 300px;
animation-name: content-show-bottom;
}

@keyframes overlay-show {
from {
opacity: 0;
}

to {
opacity: 1;
}
}

@keyframes content-show-right {
from {
opacity: 0;
transform: translate3d(100%, 0, 0);
}

to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}

@keyframes content-show-left {
from {
opacity: 0;
transform: translate3d(-100%, 0, 0);
}

to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}

@keyframes content-show-top {
from {
opacity: 0;
transform: translate3d(0, -100%, 0);
}

to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}

@keyframes content-show-bottom {
from {
opacity: 0;
transform: translate3d(0, 100%, 0);
}

to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
143 changes: 143 additions & 0 deletions packages/react/src/components/Drawer/Drawer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useRef, useState } from 'react';
import type { Meta, StoryFn } from '@storybook/react';

import { Button } from '../Button';
import { Textfield } from '../form/Textfield';
import { Paragraph } from '../Typography';
import { Divider } from '../Divider';
import { Combobox } from '..';

import { Drawer, type DrawerRef } from './Drawer';

export default {
title: 'Komponenter/Drawer',
component: Drawer,
} as Meta;

export const Preview: StoryFn<typeof Drawer> = (args) => {
const drawerRef = useRef<DrawerRef>(null);

return (
<>
<Button onClick={() => drawerRef.current?.open()}>Open Drawer</Button>
<Drawer
ref={drawerRef}
{...args}
>
<h2>Drawer Content</h2>
<Paragraph>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis
doloremque obcaecati assumenda odio ducimus sunt et.
</Paragraph>
</Drawer>
</>
);
};

export const WithBuiltInTrigger: StoryFn<typeof Drawer> = (args) => {
return (
<Drawer
trigger={<Button>Open Drawer</Button>}
{...args}
>
<h2>Drawer with Built-in Trigger</h2>
<Paragraph>This drawer uses the built-in trigger prop.</Paragraph>
</Drawer>
);
};

export const DifferentPositions: StoryFn<typeof Drawer> = () => {
const drawerRef = useRef<DrawerRef>(null);
const [position, setPosition] = useState<'left' | 'right' | 'top' | 'bottom'>(
'right',
);

return (
<>
<Button onClick={() => drawerRef.current?.open()}>Open Drawer</Button>
<select
onChange={(e) => setPosition(e.target.value as typeof position)}
value={position}
style={{ marginLeft: '10px' }}
>
<option value='left'>Left</option>
<option value='right'>Right</option>
<option value='top'>Top</option>
<option value='bottom'>Bottom</option>
</select>
<Drawer
ref={drawerRef}
position={position}
>
<h2>Drawer from {position}</h2>
<Paragraph>This drawer opens from the {position}.</Paragraph>
</Drawer>
</>
);
};

export const DrawerWithForm: StoryFn<typeof Drawer> = () => {
const drawerRef = useRef<DrawerRef>(null);
const [input, setInput] = useState('');

return (
<>
<Button onClick={() => drawerRef.current?.open()}>
Open Drawer with Form
</Button>
<Drawer ref={drawerRef}>
<h2>Drawer with Form</h2>
<Textfield
label='Name'
placeholder='John Doe'
value={input}
autoFocus
onChange={(e) => setInput(e.target.value)}
/>
<Button
onClick={() => {
window.alert(`You submitted the form with name: ${input}`);
setInput('');
drawerRef.current?.close();
}}
>
Submit Form
</Button>
</Drawer>
</>
);
};

export const DrawerWithDivider: StoryFn<typeof Drawer> = () => (
<Drawer trigger={<Button>Open Drawer with Divider</Button>}>
<h2>Drawer with Divider</h2>
<Divider color='subtle' />
<Paragraph>Content between dividers</Paragraph>
<Divider color='subtle' />
</Drawer>
);

export const DrawerWithSelect: StoryFn<typeof Drawer> = () => {
const drawerRef = useRef<DrawerRef>(null);

return (
<>
<Button onClick={() => drawerRef.current?.open()}>
Open Drawer with Select
</Button>
<Drawer
ref={drawerRef}
style={{ overflow: 'visible' }}
>
<h2>Drawer with Select</h2>
<Combobox portal={false}>
<Combobox.Empty>No results found</Combobox.Empty>
<Combobox.Option value='oslo'>Oslo</Combobox.Option>
<Combobox.Option value='bergen'>Bergen</Combobox.Option>
<Combobox.Option value='trondheim'>Trondheim</Combobox.Option>
<Combobox.Option value='stavanger'>Stavanger</Combobox.Option>
</Combobox>
</Drawer>
</>
);
};
74 changes: 74 additions & 0 deletions packages/react/src/components/Drawer/Drawer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { act, render as renderRtl, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { Drawer, type DrawerProps } from './Drawer';

const TRIGGER_TEXT = 'Open Drawer';
const DRAWER_CONTENT = 'Drawer Content';

const TestComponent = (props: Partial<DrawerProps>) => (
<Drawer
trigger={<button>{TRIGGER_TEXT}</button>}
{...props}
>
{DRAWER_CONTENT}
</Drawer>
);

const render = async (props: Partial<DrawerProps> = {}) => {
await act(async () => {});
const user = userEvent.setup();
return {
user,
...renderRtl(<TestComponent {...props} />),
};
};

describe('Drawer', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should render the trigger', async () => {
await render();
expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument();
});

it('should open the drawer', async () => {
const { user } = await render();
const trigger = screen.getByText(TRIGGER_TEXT);
await user.click(trigger);
expect(screen.getByText(DRAWER_CONTENT)).toBeInTheDocument();
});

it('should close the drawer', async () => {
const { user } = await render();
const trigger = screen.getByText(TRIGGER_TEXT);
await user.click(trigger);
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(screen.queryByText(DRAWER_CONTENT)).not.toBeInTheDocument();
});

it('should use custom aria-label for close button', async () => {
const customLabel = 'Custom Close Label';
await render({ arialabelCloseDrawer: customLabel });
const trigger = screen.getByText(TRIGGER_TEXT);
await userEvent.click(trigger);
expect(screen.getByLabelText(customLabel)).toBeInTheDocument();
});

it('should not render trigger when not provided', async () => {
await render({ trigger: undefined });
expect(screen.queryByText(TRIGGER_TEXT)).not.toBeInTheDocument();
});

it('should autofocus on close button when opened', async () => {
const { user } = await render();
const trigger = screen.getByText(TRIGGER_TEXT);
await user.click(trigger);

const closeButton = screen.getByRole('button', { name: /close/i });
expect(closeButton).toHaveFocus();
});
});
Loading
Loading