Skip to content

Commit 8ec925d

Browse files
committed
feat(design-system): rename DsWorkspace to DsWorkspaceLayout [AR-65647]
1 parent 15c3ff9 commit 8ec925d

10 files changed

Lines changed: 1568 additions & 100 deletions

File tree

.changeset/ice-banana-dance.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@drivenets/design-system': minor
3+
---
4+
5+
Add extended layout compound parts to `DsWorkspace` (`Body`, `SideMenu`, `LeftPanel`). `Content` now always applies content-area spacing — visual change for existing simple-shell consumers, API unchanged.
6+
Rename `DsWorkspace` to `DsWorkspaceLayout`.

CONTEXT.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ _Avoid_: replacement, v2 component (as a synonym for the pattern name)
4848
A **Component** (or prop type) still exported but documented as superseded; Storybook uses the `deprecated` tag.
4949
_Avoid_: legacy, old (without linking to the successor)
5050

51+
**Workspace page**:
52+
A full-screen DAP route for a multi-step **Project** (create, view, or approve) with persistent header chrome and a structured body.
53+
_Avoid_: project page, wizard page, fullscreen layout (generic)
54+
55+
**Workspace layout mode**:
56+
Either the simple shell (`Header`, optional `SubHeader`, `Content`, optional `Footer`) or the extended shell that swaps root `Content` for `Body` plus optional `SideMenu`, `LeftPanel`, and `Content` nested inside `Body`. Extended regions are opt-in; root-level `Content` keeps the simple shell default and must not change behavior for existing consumers.
57+
_Avoid_: layout prop, variant enum on the root component
58+
59+
**Workspace body**:
60+
The region below the workspace header: optional top/bottom stepper bands plus a horizontal band for navigation chrome and main work area.
61+
_Avoid_: content (when meaning the whole page), main
62+
63+
**Side menu panel** (workspace):
64+
Optional workspace-body chrome: vertical section navigation (collapsed rail, hover expand, optional pin) beside the work area; does not set **Content area** horizontal margins.
65+
_Avoid_: left nav, sidebar, catalog side menu (different page type)
66+
67+
**Left side panel** (workspace):
68+
Optional docked panel in the workspace body that consumes width and pushes the **Content area** aside; when present, **Content area** uses 24px horizontal margins.
69+
_Avoid_: drawer, right side panel, side menu panel
70+
71+
**Content area** (workspace):
72+
The primary work column (`DsWorkspace.Content`): column layout with 24px vertical margins, 16px gap, and 40px horizontal margins (24px when a **Left side panel** is present). Layout chrome only — title line and content items are consumer markup inside the slot. `SideMenu` does not affect horizontal margins.
73+
_Avoid_: main content, body, canvas (canvas is one kind of content)
74+
75+
**Right side panel** (workspace):
76+
Optional drawer overlay scoped to the **Content area** only — workspace header and body chrome (side menu, left panel) stay visible; composed with `DsDrawer` inside **Content area**, not a push layout region.
77+
_Avoid_: left side panel, side menu panel, modal, body-wide overlay (unless product explicitly requires it)
78+
5179
## Relationships
5280

5381
- A **Component** exposes **Variants** and may accept **Locale** when it shows built-in user-facing text
@@ -56,6 +84,10 @@ _Avoid_: legacy, old (without linking to the successor)
5684
- A **Successor component** supersedes a **Deprecated component**; both may ship until consumers migrate
5785
- **Tokens** flow from design into SCSS; **Components** consume tokens, not raw hex from Figma in new work
5886
- **Changesets** attach to package releases; a new **Component** or breaking API change typically needs one
87+
- A **Workspace page** has one header and one **Workspace body**; the body may combine **Side menu panel**, **Left side panel**, **Content area**, and **Right side panel**
88+
- **Side menu panel** and **Left side panel** differ: only **Left side panel** toggles **Content area** horizontal margins (24px vs 40px)
89+
- **Left side panel** pushes layout; **Right side panel** overlays **Content area** via `DsDrawer` (no symmetric right layout slot)
90+
- **Workspace layout mode** is opt-in: `Body` with `SideMenu` / `LeftPanel` adds horizontal chrome; `Content` always applies content-area spacing
5991

6092
## Example dialogue
6193

@@ -65,6 +97,12 @@ _Avoid_: legacy, old (without linking to the successor)
6597
> **Dev:** "We're shipping `DsButtonV3` but `DsButton` is still exported."
6698
> **Domain expert:** "That's a **Successor component**. Leave `DsButton` as **Deprecated component** with `@deprecated` JSDoc until product apps migrate; new internal usage should import **Successor** only."
6799
100+
> **Dev:** "Should the comments drawer cover the side menu?"
101+
> **Domain expert:** "No — **Right side panel** overlays **Content area** only. Header and body chrome stay visible; use `DsDrawer` inside **Content area**, not a full-body overlay."
102+
103+
> **Dev:** "Should we add `ContentTitle` and `ContentItems` slots?"
104+
> **Domain expert:** "No — **Content area** is `Content`. Workspace exposes layout regions; page content (title line, content items) stays in consumer children."
105+
68106
## Flagged ambiguities
69107

70108
- "Adapter" in file-upload vs "adapter" in generic architecture docs — resolved: use **Upload adapter** in design-system context; architecture skill uses **Adapter** at a **seam** ([LANGUAGE.md](.agents/skills/improve-codebase-architecture/LANGUAGE.md)).

packages/design-system/src/components/ds-workspace/__tests__/ds-workspace.browser.test.tsx

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { Component, type ReactNode } from 'react';
2+
import { describe, expect, it, vi } from 'vitest';
23
import { page } from 'vitest/browser';
34

45
import DsWorkspace from '../ds-workspace';
@@ -187,4 +188,217 @@ describe('DsWorkspace', () => {
187188
expect(dialogRect.top).toBeGreaterThanOrEqual(headerRect.bottom);
188189
expect(dialogRect.bottom).toBeLessThanOrEqual(footerRect.top + 1);
189190
});
191+
192+
it('applies content-area layout on Content', async () => {
193+
await page.render(
194+
<DsWorkspace fillParent>
195+
<DsWorkspace.Content data-testid="content">
196+
<span>Content area</span>
197+
</DsWorkspace.Content>
198+
</DsWorkspace>,
199+
);
200+
201+
const content = page.getByTestId('content').element();
202+
const style = getComputedStyle(content);
203+
204+
expect(content.className).not.toMatch(/contentWithLeftPanel/);
205+
expect(style.flexDirection).toBe('column');
206+
expect(style.paddingLeft).toBe('40px');
207+
expect(style.paddingRight).toBe('40px');
208+
expect(style.paddingTop).toBe('24px');
209+
expect(style.paddingBottom).toBe('24px');
210+
expect(style.gap).toBe('16px');
211+
});
212+
213+
describe('extended shell', () => {
214+
it('applies content-area margins when Body contains only Content', async () => {
215+
await page.render(
216+
<DsWorkspace fillParent>
217+
<DsWorkspace.Body>
218+
<DsWorkspace.Content data-testid="content">
219+
<span>Content area</span>
220+
</DsWorkspace.Content>
221+
</DsWorkspace.Body>
222+
</DsWorkspace>,
223+
);
224+
225+
const content = page.getByTestId('content').element();
226+
const style = getComputedStyle(content);
227+
228+
expect(content.className).not.toMatch(/contentWithLeftPanel/);
229+
expect(style.paddingLeft).toBe('40px');
230+
expect(style.paddingRight).toBe('40px');
231+
expect(style.paddingTop).toBe('24px');
232+
expect(style.paddingBottom).toBe('24px');
233+
expect(style.gap).toBe('16px');
234+
});
235+
236+
it('applies reduced horizontal margins when LeftPanel is mounted', async () => {
237+
await page.render(
238+
<DsWorkspace fillParent>
239+
<DsWorkspace.Body>
240+
<DsWorkspace.LeftPanel>
241+
<span>Left panel</span>
242+
</DsWorkspace.LeftPanel>
243+
<DsWorkspace.Content data-testid="content">
244+
<span>Content area</span>
245+
</DsWorkspace.Content>
246+
</DsWorkspace.Body>
247+
</DsWorkspace>,
248+
);
249+
250+
const content = page.getByTestId('content').element();
251+
const style = getComputedStyle(content);
252+
253+
expect(content.className).toMatch(/contentWithLeftPanel/);
254+
expect(style.paddingLeft).toBe('24px');
255+
expect(style.paddingRight).toBe('24px');
256+
});
257+
258+
it('toggles Content margin class when LeftPanel is mounted and unmounted', async () => {
259+
const WorkspaceWithOptionalLeftPanel = ({ showLeftPanel }: { showLeftPanel: boolean }) => (
260+
<DsWorkspace fillParent>
261+
<DsWorkspace.Body>
262+
{showLeftPanel ? (
263+
<DsWorkspace.LeftPanel>
264+
<span>Left panel</span>
265+
</DsWorkspace.LeftPanel>
266+
) : null}
267+
<DsWorkspace.Content data-testid="content">
268+
<span>Content area</span>
269+
</DsWorkspace.Content>
270+
</DsWorkspace.Body>
271+
</DsWorkspace>
272+
);
273+
274+
const { rerender } = await page.render(<WorkspaceWithOptionalLeftPanel showLeftPanel={false} />);
275+
276+
let content = page.getByTestId('content').element();
277+
expect(content.className).not.toMatch(/contentWithLeftPanel/);
278+
expect(getComputedStyle(content).paddingLeft).toBe('40px');
279+
280+
await rerender(<WorkspaceWithOptionalLeftPanel showLeftPanel={true} />);
281+
282+
content = page.getByTestId('content').element();
283+
expect(content.className).toMatch(/contentWithLeftPanel/);
284+
expect(getComputedStyle(content).paddingLeft).toBe('24px');
285+
286+
await rerender(<WorkspaceWithOptionalLeftPanel showLeftPanel={false} />);
287+
288+
content = page.getByTestId('content').element();
289+
expect(content.className).not.toMatch(/contentWithLeftPanel/);
290+
expect(getComputedStyle(content).paddingLeft).toBe('40px');
291+
});
292+
293+
it('does not change Content horizontal margins when SideMenu is present', async () => {
294+
await page.render(
295+
<DsWorkspace fillParent>
296+
<DsWorkspace.Body>
297+
<DsWorkspace.SideMenu>
298+
<span>Nav</span>
299+
</DsWorkspace.SideMenu>
300+
<DsWorkspace.Content data-testid="content">
301+
<span>Content area</span>
302+
</DsWorkspace.Content>
303+
</DsWorkspace.Body>
304+
</DsWorkspace>,
305+
);
306+
307+
const content = page.getByTestId('content').element();
308+
const style = getComputedStyle(content);
309+
310+
expect(content.className).not.toMatch(/contentWithLeftPanel/);
311+
expect(style.paddingLeft).toBe('40px');
312+
expect(style.paddingRight).toBe('40px');
313+
});
314+
315+
it('content in Body creates a stacking context for drawer containment', async () => {
316+
await page.render(
317+
<DsWorkspace fillParent>
318+
<DsWorkspace.Body>
319+
<DsWorkspace.Content data-testid="content">
320+
<span>Content area</span>
321+
</DsWorkspace.Content>
322+
</DsWorkspace.Body>
323+
</DsWorkspace>,
324+
);
325+
326+
const contentEl = page.getByTestId('content').element();
327+
const style = getComputedStyle(contentEl);
328+
329+
expect(style.position).toBe('relative');
330+
});
331+
332+
it('drawer inside Body Content is positioned below header', async () => {
333+
await page.render(
334+
<div style={{ height: 500 }}>
335+
<DsWorkspace fillParent>
336+
<DsWorkspace.Header>
337+
<span>Header</span>
338+
</DsWorkspace.Header>
339+
<DsWorkspace.Body>
340+
<DsWorkspace.Content>
341+
<div style={{ position: 'absolute', inset: 0 }} role="dialog" aria-label="Drawer">
342+
Drawer
343+
</div>
344+
<span>Main area</span>
345+
</DsWorkspace.Content>
346+
</DsWorkspace.Body>
347+
<DsWorkspace.Footer>
348+
<span>Footer</span>
349+
</DsWorkspace.Footer>
350+
</DsWorkspace>
351+
</div>,
352+
);
353+
354+
const headerRect = page.getByRole('banner').element().getBoundingClientRect();
355+
const dialogRect = page.getByRole('dialog').element().getBoundingClientRect();
356+
const footerRect = page.getByRole('contentinfo').element().getBoundingClientRect();
357+
358+
expect(dialogRect.top).toBeGreaterThanOrEqual(headerRect.bottom);
359+
expect(dialogRect.bottom).toBeLessThanOrEqual(footerRect.top + 1);
360+
});
361+
362+
it('throws when extended compound parts are used outside DsWorkspace', async () => {
363+
class TestErrorBoundary extends Component<
364+
{ children: ReactNode; onError: (error: Error) => void },
365+
{ error: Error | null }
366+
> {
367+
override state = { error: null as Error | null };
368+
369+
static getDerivedStateFromError(error: Error) {
370+
return { error };
371+
}
372+
373+
override componentDidCatch(error: Error) {
374+
this.props.onError(error);
375+
}
376+
377+
override render() {
378+
if (this.state.error) {
379+
return null;
380+
}
381+
382+
return this.props.children;
383+
}
384+
}
385+
386+
const onError = vi.fn<(error: Error) => void>();
387+
388+
await page.render(
389+
<TestErrorBoundary onError={onError}>
390+
<DsWorkspace.Body>
391+
<DsWorkspace.Content>
392+
<span>Content only</span>
393+
</DsWorkspace.Content>
394+
</DsWorkspace.Body>
395+
</TestErrorBoundary>,
396+
);
397+
398+
expect(onError).toHaveBeenCalledOnce();
399+
expect(onError.mock.calls[0]?.[0]?.message).toBe(
400+
'DsWorkspace compound components must be used within DsWorkspace',
401+
);
402+
});
403+
});
190404
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createContext, useContext } from 'react';
2+
3+
export interface DsWorkspaceContextValue {
4+
hasLeftSidePanel: boolean;
5+
registerLeftSidePanel: () => void;
6+
unregisterLeftSidePanel: () => void;
7+
}
8+
9+
export const DsWorkspaceContext = createContext<DsWorkspaceContextValue | null>(null);
10+
11+
export const useDsWorkspaceContext = () => {
12+
const context = useContext(DsWorkspaceContext);
13+
14+
if (!context) {
15+
throw new Error('DsWorkspace compound components must be used within DsWorkspace');
16+
}
17+
18+
return context;
19+
};

0 commit comments

Comments
 (0)