Skip to content

Commit f45a048

Browse files
authored
feat(design-system): add width, style props to dspanel [AR-53856] (#366)
1 parent 22bd508 commit f45a048

7 files changed

Lines changed: 180 additions & 110 deletions

File tree

.changeset/lazy-trees-attend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@drivenets/design-system': patch
3+
---
4+
5+
Add `width` (responsive), `style` props to DsPanel
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useState } from 'react';
2+
import { describe, expect, it } from 'vitest';
3+
import { page } from 'vitest/browser';
4+
5+
import { DsPanel } from '../';
6+
import { DsButtonV3 } from '../../ds-button-v3';
7+
8+
function CollapsiblePanel({ variant }: { variant?: 'docked' | 'floating' }) {
9+
const [open, setOpen] = useState(true);
10+
11+
return (
12+
<>
13+
{!open && <DsButtonV3 onClick={() => setOpen(true)}>Open Panel</DsButtonV3>}
14+
15+
<DsPanel open={open} onOpenChange={setOpen} variant={variant}>
16+
<p>This is a panel</p>
17+
</DsPanel>
18+
</>
19+
);
20+
}
21+
22+
describe('DsPanel', () => {
23+
describe('collapse and expand', () => {
24+
it('should collapse and reopen in docked variant', async () => {
25+
await page.render(<CollapsiblePanel variant="docked" />);
26+
27+
await expect.element(page.getByText('This is a panel')).toBeVisible();
28+
29+
await page.getByText('This is a panel').hover();
30+
await page.getByLabelText('Toggle panel').click();
31+
await expect.element(page.getByText('This is a panel')).not.toBeVisible();
32+
33+
await page.getByText('Open Panel').click();
34+
await expect.element(page.getByText('This is a panel')).toBeVisible();
35+
});
36+
37+
it('should collapse and reopen in floating variant', async () => {
38+
await page.render(
39+
<div style={{ position: 'relative', width: 600, height: 400 }}>
40+
<CollapsiblePanel variant="floating" />
41+
</div>,
42+
);
43+
44+
await expect.element(page.getByText('This is a panel')).toBeVisible();
45+
46+
await page.getByText('This is a panel').hover();
47+
await page.getByLabelText('Toggle panel').click();
48+
await expect.element(page.getByText('This is a panel')).not.toBeVisible();
49+
50+
await page.getByText('Open Panel').click();
51+
await expect.element(page.getByText('This is a panel')).toBeVisible();
52+
});
53+
});
54+
55+
describe('draggable', () => {
56+
function simulateDrag(target: HTMLElement, deltaX: number, deltaY: number) {
57+
const startX = 100;
58+
const startY = 100;
59+
60+
target.dispatchEvent(new MouseEvent('mousedown', { clientX: startX, clientY: startY, bubbles: true }));
61+
document.dispatchEvent(
62+
new MouseEvent('mousemove', { clientX: startX + deltaX, clientY: startY + deltaY, bubbles: true }),
63+
);
64+
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
65+
}
66+
67+
it('should move panel when dragging by handle', async () => {
68+
await page.render(
69+
<div style={{ position: 'relative', width: 600, height: 500 }}>
70+
<DsPanel open variant="floating" draggable>
71+
<span data-drag-handle="">drag_indicator</span>
72+
<p>Panel content</p>
73+
</DsPanel>
74+
</div>,
75+
);
76+
77+
const handle = page.getByText('drag_indicator').element() as HTMLElement;
78+
const panel = handle.closest('[data-state]') as HTMLElement;
79+
const rectBefore = panel.getBoundingClientRect();
80+
81+
simulateDrag(handle, 100, 50);
82+
83+
const rectAfter = panel.getBoundingClientRect();
84+
85+
expect(Math.round(rectAfter.left - rectBefore.left)).toBe(100);
86+
expect(Math.round(rectAfter.top - rectBefore.top)).toBe(50);
87+
});
88+
89+
it('should not drag when clicking outside the handle', async () => {
90+
await page.render(
91+
<div style={{ position: 'relative', width: 600, height: 500 }}>
92+
<DsPanel open variant="floating" draggable>
93+
<span data-drag-handle="">drag_indicator</span>
94+
<p>Panel content</p>
95+
</DsPanel>
96+
</div>,
97+
);
98+
99+
const label = page.getByText('Panel content').element() as HTMLElement;
100+
const panel = label.closest('[data-state]') as HTMLElement;
101+
const rectBefore = panel.getBoundingClientRect();
102+
103+
simulateDrag(label, 200, 200);
104+
105+
const rectAfter = panel.getBoundingClientRect();
106+
107+
expect(rectAfter.left).toBe(rectBefore.left);
108+
expect(rectAfter.top).toBe(rectBefore.top);
109+
});
110+
});
111+
112+
describe('width', () => {
113+
it('should apply default responsive width when no width prop is provided', async () => {
114+
await page.render(<DsPanel open>Content</DsPanel>);
115+
116+
const panel = page.getByText('Content').element().closest('[data-state]') as HTMLElement;
117+
const { width } = panel.getBoundingClientRect();
118+
119+
const expectedWidth = Math.min(480, Math.max(240, window.innerWidth * 0.2));
120+
expect(Math.round(width)).toBe(Math.round(expectedWidth));
121+
});
122+
123+
it('should apply explicit width when provided', async () => {
124+
await page.render(
125+
<DsPanel open width={350}>
126+
Content
127+
</DsPanel>,
128+
);
129+
130+
const panel = page.getByText('Content').element().closest('[data-state]') as HTMLElement;
131+
const { width } = panel.getBoundingClientRect();
132+
133+
expect(Math.round(width)).toBe(350);
134+
});
135+
});
136+
});

packages/design-system/src/components/ds-panel/ds-panel.module.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
position: relative;
33
background: var(--white);
44
padding: var(--spacing-6);
5-
width: 340px;
5+
width: 20vw;
6+
min-width: 240px;
7+
max-width: 480px;
8+
height: 100vh;
69
border-inline-end: 1px solid transparent;
710

811
&[data-state='open']:hover,
Lines changed: 15 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
2-
import { DsPanel } from './ds-panel';
2+
import { DsPanel } from './';
33
import { DsButton } from '../ds-button/';
44
import { DsStepper, DsStep, DsStepContent, DsNextStepButton } from '../ds-stepper';
55
import { useState } from 'react';
6-
import { expect, userEvent } from 'storybook/test';
76
import type { DsPanelVariant } from './ds-panel.types';
87

98
export default {
@@ -36,31 +35,24 @@ export const Default: Story = {
3635
</>
3736
);
3837
},
38+
};
3939

40-
play: async ({ canvas, step, args, initialArgs }) => {
41-
const panelTrigger = canvas.getByLabelText('Toggle panel');
42-
43-
const testVariant = async (variant: DsPanelVariant) => {
44-
args.variant = variant;
45-
46-
await step(`Close Panel - ${variant}`, async () => {
47-
await userEvent.click(panelTrigger);
48-
49-
await expect(canvas.queryByText(/This is a panel/)).not.toBeVisible();
50-
});
40+
export const Responsive: Story = {
41+
render: function Render() {
42+
const [open, setOpen] = useState(true);
5143

52-
await step(`Open Panel - ${variant}`, async () => {
53-
await userEvent.click(canvas.getByText('Open Panel'));
44+
return (
45+
<>
46+
{!open && <DsButton onClick={() => setOpen(true)}>Open Panel</DsButton>}
5447

55-
await expect(canvas.getByText(/This is a panel/)).toBeVisible();
56-
});
48+
<DsPanel open={open} onOpenChange={setOpen} width={{ lg: 480, md: 240 }}>
49+
<p>This panel uses a responsive width.</p>
50+
<p>Large screens: 480px. Medium screens: 240px.</p>
5751

58-
// Reset state.
59-
args.variant = initialArgs.variant;
60-
};
61-
62-
await testVariant('docked');
63-
await testVariant('floating');
52+
<DsButton size="small">Primary Action</DsButton>
53+
</DsPanel>
54+
</>
55+
);
6456
},
6557
};
6658

@@ -119,85 +111,4 @@ export const Draggable: Story = {
119111
</div>
120112
);
121113
},
122-
123-
play: async ({ canvas, step }) => {
124-
const togglePanel = async () => {
125-
await userEvent.click(canvas.getByLabelText('Toggle panel'));
126-
};
127-
128-
const getPanel = () => canvas.getByText('Configure network').closest('[data-state]') as HTMLElement;
129-
130-
const dockedRect = getPanel().getBoundingClientRect();
131-
132-
await step('Docked - all steps and descriptions visible', async () => {
133-
await expect(canvas.getByText('Configure network')).toBeVisible();
134-
await expect(canvas.getByText('Assign resources')).toBeVisible();
135-
await expect(canvas.getByText('Review & deploy')).toBeVisible();
136-
await expect(canvas.getByText(/Set up interfaces/)).toBeVisible();
137-
});
138-
139-
await step('Collapse to floating - single step with drag handle', async () => {
140-
await togglePanel();
141-
142-
await expect(canvas.getByText('Configure network')).toBeVisible();
143-
await expect(canvas.getByText('drag_indicator')).toBeVisible();
144-
await expect(canvas.queryByText(/Set up interfaces/)).not.toBeInTheDocument();
145-
});
146-
147-
await step('Drag floating panel', async () => {
148-
const handle = canvas.getByText('drag_indicator');
149-
const panel = getPanel();
150-
const rectBefore = panel.getBoundingClientRect();
151-
152-
await userEvent.pointer([
153-
{ target: handle, keys: '[MouseLeft>]', coords: { clientX: 100, clientY: 100 } },
154-
{ coords: { clientX: 200, clientY: 150 } },
155-
{ keys: '[/MouseLeft]' },
156-
]);
157-
158-
const rectAfter = panel.getBoundingClientRect();
159-
160-
await expect(Math.round(rectAfter.left - rectBefore.left)).toBe(100);
161-
await expect(Math.round(rectAfter.top - rectBefore.top)).toBe(50);
162-
});
163-
164-
await step('Non-handle area does not trigger drag', async () => {
165-
const label = canvas.getByText('Configure network');
166-
const panel = getPanel();
167-
const rectBefore = panel.getBoundingClientRect();
168-
169-
await userEvent.pointer([
170-
{ target: label, keys: '[MouseLeft>]', coords: { clientX: 0, clientY: 0 } },
171-
{ coords: { clientX: 200, clientY: 200 } },
172-
{ keys: '[/MouseLeft]' },
173-
]);
174-
175-
const rectAfter = panel.getBoundingClientRect();
176-
177-
await expect(rectAfter.left).toBe(rectBefore.left);
178-
await expect(rectAfter.top).toBe(rectBefore.top);
179-
});
180-
181-
await step('Navigate steps while floating', async () => {
182-
await userEvent.click(canvas.getByRole('button', { name: /next/i }));
183-
184-
await expect(canvas.getByText('Assign resources')).toBeVisible();
185-
});
186-
187-
await step('Expand back to docked - full content restored', async () => {
188-
await togglePanel();
189-
190-
await expect(canvas.getByText('Configure network')).toBeVisible();
191-
await expect(canvas.getByText('Assign resources')).toBeVisible();
192-
await expect(canvas.getByText('Review & deploy')).toBeVisible();
193-
await expect(canvas.queryByText('drag_indicator')).not.toBeInTheDocument();
194-
});
195-
196-
await step('Drag position resets after expanding', async () => {
197-
const resetRect = getPanel().getBoundingClientRect();
198-
199-
await expect(resetRect.left).toBe(dockedRect.left);
200-
await expect(resetRect.top).toBe(dockedRect.top);
201-
});
202-
},
203114
};

packages/design-system/src/components/ds-panel/ds-panel.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as Collapsible from '@radix-ui/react-collapsible';
33
import classnames from 'classnames';
44
import styles from './ds-panel.module.scss';
55
import { DsIcon } from '../ds-icon';
6-
import type { DsPanelCollapseButtonProps, DsPanelProps } from './ds-panel.types';
6+
import type { DsPanelBaseProps, DsPanelCollapseButtonProps } from './ds-panel.types';
77

88
function useDraggable(enabled: boolean) {
99
const rootRef = useRef<HTMLDivElement>(null);
@@ -84,20 +84,24 @@ function useDraggable(enabled: boolean) {
8484
return rootRef;
8585
}
8686

87-
export function DsPanel({
87+
export function DsPanelBase({
8888
open,
8989
onOpenChange,
9090
children,
9191
className,
92+
style,
9293
slotProps,
9394
variant = 'docked',
95+
width,
9496
draggable = false,
9597
disablePadding = false,
9698
...props
97-
}: DsPanelProps) {
99+
}: DsPanelBaseProps) {
98100
const isDraggable = variant === 'floating' && draggable;
99101
const rootRef = useDraggable(isDraggable);
100102

103+
const rootStyle = width ? { ...style, width } : style;
104+
101105
return (
102106
<Collapsible.Root
103107
ref={rootRef}
@@ -108,6 +112,7 @@ export function DsPanel({
108112
[styles.variantFloating]: variant === 'floating',
109113
[styles.disablePadding]: disablePadding,
110114
})}
115+
style={rootStyle}
111116
{...props}
112117
>
113118
<DsPanelCollapseButton {...slotProps?.collapseButton} />

packages/design-system/src/components/ds-panel/ds-panel.types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type * as Collapsible from '@radix-ui/react-collapsible';
22

3+
import type { ResponsiveValue } from '../../utils/responsive';
4+
35
export type DsPanelVariant = 'docked' | 'floating';
46

5-
export type DsPanelProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> & {
7+
export type DsPanelBaseProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> & {
68
variant?: DsPanelVariant;
9+
width?: number;
710
draggable?: boolean;
811
disablePadding?: boolean;
912
slotProps?: {
@@ -12,6 +15,10 @@ export type DsPanelProps = Omit<Collapsible.CollapsibleProps, 'onOpenChange'> &
1215
onOpenChange?: (open: boolean) => void;
1316
};
1417

18+
export type DsPanelProps = Omit<DsPanelBaseProps, 'width'> & {
19+
width?: ResponsiveValue<number>;
20+
};
21+
1522
export type DsPanelCollapseButtonProps = {
1623
onClick?: () => void;
1724
collapsed?: boolean;
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1-
export { DsPanel } from './ds-panel';
1+
import { withResponsiveProps } from '../../utils/responsive';
2+
import { DsPanelBase } from './ds-panel';
3+
4+
export const DsPanel = withResponsiveProps(DsPanelBase, ['width']);
25
export type { DsPanelProps, DsPanelVariant, DsPanelCollapseButtonProps } from './ds-panel.types';

0 commit comments

Comments
 (0)