Skip to content

Commit bab0b83

Browse files
author
Kemal Pajevic
committed
Added a vertical stepper component.
1 parent f2f1977 commit bab0b83

13 files changed

Lines changed: 381 additions & 0 deletions

libs/designsystem/shared/src/translation/translation.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ export interface Translation {
1212
characters: string;
1313
outOf: string;
1414
entered: string;
15+
stepCompleted: string;
16+
stepInProgress: string;
17+
stepPending: string;
1518
}

libs/designsystem/shared/src/translation/translations/da.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ export const da: Translation = {
1414
characters: 'Tegn',
1515
outOf: 'af',
1616
entered: 'indtastet',
17+
stepCompleted: 'Gennemført',
18+
stepInProgress: 'I gang',
19+
stepPending: 'Afventer',
1720
};

libs/designsystem/shared/src/translation/translations/de.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ export const de: Translation = {
1414
characters: 'Zeichen',
1515
outOf: 'von',
1616
entered: 'eingegeben',
17+
stepCompleted: 'Abgeschlossen',
18+
stepInProgress: 'In Bearbeitung',
19+
stepPending: 'Ausstehend',
1720
};

libs/designsystem/shared/src/translation/translations/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ export const en: Translation = {
1414
characters: 'Characters',
1515
outOf: 'out of',
1616
entered: 'entered',
17+
stepCompleted: 'Completed',
18+
stepInProgress: 'In progress',
19+
stepPending: 'Pending',
1720
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/index.ts"
4+
}
5+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { VerticalStepperComponent } from './vertical-stepper.component';
2+
export { VerticalStepperStep } from './vertical-stepper.types';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<ol class="steps">
2+
@for (step of stepStates(); track step.title; let i = $index) {
3+
<li
4+
class="step"
5+
[class.completed]="step.completed"
6+
[class.active]="step.active"
7+
[class.has-description]="step.description"
8+
[attr.aria-labelledby]="'step-title-' + i"
9+
[attr.aria-describedby]="'step-status-' + i"
10+
>
11+
<span class="kirby-visually-hidden" [id]="'step-status-' + i">
12+
{{ statusLabel(step) }}
13+
</span>
14+
<div class="status">
15+
<kirby-icon name="verify" size="sm"></kirby-icon>
16+
</div>
17+
<div class="text">
18+
<p [id]="'step-title-' + i" class="title kirby-text-normal">
19+
{{ step.title }}
20+
</p>
21+
@if (step.description) {
22+
<p class="description kirby-text-xsmall">{{ step.description }}</p>
23+
}
24+
</div>
25+
</li>
26+
}
27+
</ol>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
$status-size: var(--kirby-spacing-l);
2+
$status-margin-end: var(--kirby-spacing-s);
3+
$line-width: 1px;
4+
5+
:host {
6+
display: block;
7+
8+
// Allows the step indicators to blend into the surrounding surface. Override if placed on a non-default background.
9+
--kirby-x-vertical-stepper-background-color: var(--kirby-white);
10+
}
11+
12+
.title {
13+
font-weight: var(--kirby-font-weight-bold);
14+
margin-bottom: 0;
15+
}
16+
17+
.description {
18+
margin-bottom: 0;
19+
white-space: pre-wrap;
20+
}
21+
22+
.status {
23+
flex: 1 1 calc(#{$status-size} + #{$status-margin-end});
24+
height: $status-size;
25+
max-width: $status-size;
26+
margin-inline-end: $status-margin-end;
27+
border-radius: var(--kirby-border-radius-circle);
28+
border: solid $line-width var(--kirby-medium);
29+
background-color: var(--kirby-x-vertical-stepper-background-color);
30+
display: flex;
31+
justify-content: center;
32+
align-items: center;
33+
34+
> * {
35+
display: none;
36+
}
37+
}
38+
39+
ul > li,
40+
ol > li {
41+
margin: 0;
42+
}
43+
44+
.steps {
45+
list-style: none;
46+
padding: 0;
47+
color: var(--kirby-text-color-semi-dark);
48+
49+
.step {
50+
position: relative;
51+
display: flex;
52+
align-items: center;
53+
flex-wrap: wrap;
54+
55+
&.has-description {
56+
align-items: flex-start;
57+
58+
.text {
59+
margin-top: -6px;
60+
}
61+
}
62+
63+
> *:not(.status) {
64+
flex: 1;
65+
}
66+
}
67+
68+
.step:not(:last-child) {
69+
padding-bottom: var(--kirby-spacing-m);
70+
71+
// Line between the steps
72+
&::before {
73+
content: '';
74+
position: absolute;
75+
border-inline-start: $line-width dashed var(--kirby-semi-dark);
76+
height: 100%;
77+
top: 50%;
78+
inset-inline-start: calc(#{$status-size} * 0.5);
79+
z-index: -1;
80+
}
81+
82+
&.completed::before {
83+
border-inline-start: $line-width solid var(--kirby-black);
84+
}
85+
86+
&.has-description::before {
87+
top: 0;
88+
}
89+
}
90+
}
91+
92+
.step.completed,
93+
.step.active {
94+
color: var(--kirby-text-color-black);
95+
}
96+
97+
.step.completed .status {
98+
background-color: var(--kirby-x-vertical-stepper-background-color);
99+
border-color: var(--kirby-x-vertical-stepper-background-color);
100+
101+
kirby-icon {
102+
display: flex;
103+
}
104+
}
105+
106+
.step.active .status {
107+
background-color: var(--kirby-black);
108+
border-color: var(--kirby-black);
109+
110+
&::after {
111+
content: '';
112+
width: calc(#{$status-size} * 0.2);
113+
height: calc(#{$status-size} * 0.2);
114+
background-color: var(--kirby-white);
115+
border-radius: var(--kirby-border-radius-circle);
116+
}
117+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
2+
import { VerticalStepperComponent } from './vertical-stepper.component';
3+
import { VerticalStepperStep } from './vertical-stepper.types';
4+
5+
describe('VerticalStepperComponent', () => {
6+
let spectator: SpectatorHost<VerticalStepperComponent>;
7+
const createHost = createHostFactory({
8+
component: VerticalStepperComponent,
9+
});
10+
11+
const steps: VerticalStepperStep[] = [
12+
{ title: 'Horse', description: '' },
13+
{ title: 'Dog', description: 'A cosy animal' },
14+
{ title: 'Mosquito', description: 'An annoying animal' },
15+
{ title: 'Leopard', description: 'A cool animal' },
16+
];
17+
18+
function createComponent(activeIndex: number) {
19+
spectator = createHost(
20+
`<kirby-x-vertical-stepper [steps]="steps" [activeIndex]="activeIndex"></kirby-x-vertical-stepper>`,
21+
{ hostProps: { steps, activeIndex } }
22+
);
23+
}
24+
25+
it('should create', () => {
26+
createComponent(0);
27+
expect(spectator.component).toBeTruthy();
28+
});
29+
30+
it('should render a step for each item, with title and description', () => {
31+
createComponent(0);
32+
const titles = spectator.queryAll('.step .title');
33+
const descriptions = spectator.queryAll('.step .description');
34+
35+
expect(titles).toHaveLength(4);
36+
expect(titles[0].textContent?.trim()).toEqual('Horse');
37+
expect(titles[1].textContent?.trim()).toEqual('Dog');
38+
39+
// Steps without a description should not render the description element
40+
expect(descriptions).toHaveLength(3);
41+
expect(descriptions[0].textContent?.trim()).toEqual('A cosy animal');
42+
});
43+
44+
describe('when no steps are completed', () => {
45+
beforeEach(() => createComponent(0));
46+
47+
it('should not have any completed steps', () => {
48+
expect(spectator.query('.step.completed')).toBeNull();
49+
});
50+
51+
it('should have the first step active', () => {
52+
expect(spectator.query('.step.active .title')?.textContent?.trim()).toEqual('Horse');
53+
});
54+
});
55+
56+
describe('when the first step is completed', () => {
57+
beforeEach(() => createComponent(1));
58+
59+
it('should mark the first step as completed', () => {
60+
expect(spectator.query('.step.completed .title')?.textContent?.trim()).toEqual('Horse');
61+
});
62+
63+
it('should mark the second step as active', () => {
64+
expect(spectator.query('.step.active .title')?.textContent?.trim()).toEqual('Dog');
65+
});
66+
});
67+
68+
describe('when the first two steps are completed', () => {
69+
beforeEach(() => createComponent(2));
70+
71+
it('should mark the first two steps as completed', () => {
72+
const completedTitles = spectator.queryAll('.step.completed .title');
73+
expect(completedTitles).toHaveLength(2);
74+
expect(completedTitles[0].textContent?.trim()).toEqual('Horse');
75+
expect(completedTitles[1].textContent?.trim()).toEqual('Dog');
76+
});
77+
78+
it('should mark the third step as active', () => {
79+
expect(spectator.query('.step.active .title')?.textContent?.trim()).toEqual('Mosquito');
80+
});
81+
});
82+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { type Meta, type StoryObj } from '@storybook/angular';
2+
import { VerticalStepperComponent } from '@kirbydesign/extensions-angular/vertical-stepper';
3+
4+
/**
5+
* The vertical stepper visualizes progress through a sequential flow, such as an order or onboarding process.
6+
* Each step can be pending, active (in progress), or completed.
7+
*
8+
* ## Usage Guidelines
9+
* - Use the vertical stepper to communicate _where_ a user is in a multi-step flow, not to navigate between steps.
10+
* - Keep step titles short. Use the optional description to add supporting detail for a step.
11+
*/
12+
const meta: Meta<VerticalStepperComponent> = {
13+
component: VerticalStepperComponent,
14+
title: 'Components/Vertical Stepper',
15+
args: {
16+
steps: [
17+
{ title: 'Order received' },
18+
{ title: 'Processing order', description: 'Your order is being processed.' },
19+
{ title: 'Order sent', description: 'Your order is on its way to you.' },
20+
{ title: 'Order delivered' },
21+
],
22+
activeIndex: 1,
23+
},
24+
argTypes: {
25+
activeIndex: { control: { type: 'number', min: 0 } },
26+
},
27+
};
28+
export default meta;
29+
type Story = StoryObj<VerticalStepperComponent>;
30+
31+
/**
32+
* By default, steps before `activeIndex` are shown as completed, the step at `activeIndex` is shown as active/in-progress,
33+
* and the remaining steps are shown as pending.
34+
*/
35+
export const Default: Story = {};
36+
37+
/**
38+
* All steps are shown as pending when `activeIndex` is `0`.
39+
*/
40+
export const FirstStepActive: Story = {
41+
args: {
42+
activeIndex: 0,
43+
},
44+
};
45+
46+
/**
47+
* All steps are shown as completed when `activeIndex` is equal to, or greater than, the number of steps.
48+
*/
49+
export const AllStepsCompleted: Story = {
50+
args: {
51+
activeIndex: 4,
52+
},
53+
};
54+
55+
/**
56+
* Steps can optionally have a description providing more detail, shown below the title.
57+
*/
58+
export const WithoutDescriptions: Story = {
59+
args: {
60+
steps: [
61+
{ title: 'Order received' },
62+
{ title: 'Processing order' },
63+
{ title: 'Order sent' },
64+
{ title: 'Order delivered' },
65+
],
66+
},
67+
};

0 commit comments

Comments
 (0)