feat(stepper): implement multi-step wizard for appointment confirmation process

This commit is contained in:
hamed
2026-08-09 09:12:00 +03:30
parent 948f59827a
commit 3ffab2bbd0
7 changed files with 544 additions and 81 deletions
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import Stepper from './Stepper';
const STEPS = [
{ key: 'cost', title: 'بیمه و هزینه' },
{ key: 'payment', title: 'پرداخت' },
{ key: 'review', title: 'تأیید' },
];
const items = () => screen.getAllByRole('listitem');
describe('Stepper', () => {
it('همهٔ مراحل را با شمارهٔ خودشان نشان می‌دهد', () => {
render(<Stepper steps={STEPS} current="payment" ariaLabel="مراحل" />);
expect(items()).toHaveLength(3);
expect(items()[0].textContent).toBe('1بیمه و هزینه');
expect(items()[2].textContent).toBe('3تأیید');
});
it('مرحلهٔ جاری با aria-current اعلام می‌شود', () => {
render(<Stepper steps={STEPS} current="payment" ariaLabel="مراحل" />);
// فقط یکی؛ وگرنه صفحه‌خوان دو «مرحلهٔ فعلی» می‌خواند.
const marked = items().filter(li => li.getAttribute('aria-current') === 'step');
expect(marked).toHaveLength(1);
expect(marked[0].textContent).toBe('2پرداخت');
});
it('نوار با برچسبِ داده‌شده قابل پیدا شدن است', () => {
render(<Stepper steps={STEPS} current="cost" ariaLabel="مراحل قطعی کردن نوبت" />);
expect(screen.getByRole('list', { name: 'مراحل قطعی کردن نوبت' })).toBeInTheDocument();
});
it('مرحلهٔ ناشناخته هیچ‌کدام را فعال نمی‌کند و خطا نمی‌دهد', () => {
render(<Stepper steps={STEPS} current="unknown" ariaLabel="مراحل" />);
expect(items().filter(li => li.getAttribute('aria-current') === 'step')).toHaveLength(0);
});
});
+68
View File
@@ -0,0 +1,68 @@
export interface Step {
key: string;
title: string;
}
/**
* نوار مراحلِ یک فرمِ چندمرحله‌ای — «کجای کارم و چقدر مانده».
*
* فرم‌های بلندِ مودال (ثبت نوبت، قطعی کردن نوبت) همهٔ تصمیم‌ها را با هم نشان می‌دادند
* و از ارتفاع صفحه بلندتر می‌شدند. حالا هر بار یک مرحله دیده می‌شود و این نوار تنها
* چیزی است که کلِ مسیر را می‌گوید.
*
* فقط نمایش است: پیمایش با دکمه‌های فوتر انجام می‌شود، نه با کلیک روی مراحل — پرشِ
* دلخواه یعنی رسیدن به مرحله‌ای که پیش‌نیازش پر نشده.
*/
export default function Stepper({
steps, current, ariaLabel,
}: { steps: Step[]; current: string; ariaLabel: string }) {
const activeIdx = steps.findIndex(s => s.key === current);
return (
<ol
aria-label={ariaLabel}
style={{
display: 'flex', alignItems: 'center', gap: 8, listStyle: 'none',
margin: '0 0 18px', padding: 0,
}}
>
{steps.map((step, idx) => {
const done = idx < activeIdx;
const active = idx === activeIdx;
return (
<li
key={step.key}
aria-current={active ? 'step' : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 8,
flex: idx === steps.length - 1 ? '0 0 auto' : 1,
}}
>
<span style={{
display: 'grid', placeItems: 'center', width: 22, height: 22, borderRadius: 999,
flexShrink: 0, fontSize: 11.5, fontWeight: 700,
background: active || done ? 'var(--primary)' : 'var(--surface-3)',
color: active || done ? 'var(--on-primary)' : 'var(--text-3)',
}}>
{idx + 1}
</span>
<span style={{
fontSize: 12.5, whiteSpace: 'nowrap',
fontWeight: active ? 700 : 500,
color: active ? 'var(--text)' : 'var(--text-3)',
}}>
{step.title}
</span>
{idx < steps.length - 1 && (
<span style={{
flex: 1, height: 1, minWidth: 12,
background: done ? 'var(--primary)' : 'var(--border)',
}} />
)}
</li>
);
})}
</ol>
);
}