@umituz/web-design-system 4.34.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/components.smoke.test.tsx +34 -57
- package/src/__tests__/modal.test.tsx +46 -0
- package/src/__tests__/plan-picker.test.tsx +81 -0
- package/src/__tests__/stepper.test.tsx +100 -0
- package/src/presentation/organisms/Modal.tsx +15 -3
- package/src/presentation/organisms/PlanPicker.tsx +202 -0
- package/src/presentation/organisms/Stepper.tsx +146 -0
- package/src/presentation/organisms/index.ts +6 -8
- package/src/presentation/organisms/CalendarBoard.tsx +0 -298
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { Stepper } from '../presentation/organisms/Stepper';
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
const steps = [
|
|
8
|
+
{ id: 'a', label: 'Account' },
|
|
9
|
+
{ id: 'b', label: 'Profile' },
|
|
10
|
+
{ id: 'c', label: 'Review' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe('Stepper status derivation', () => {
|
|
14
|
+
it('marks only the current step with aria-current', () => {
|
|
15
|
+
render(<Stepper steps={steps} currentStep={1} ariaLabel="Progress" />);
|
|
16
|
+
const list = screen.getByRole('list', { name: 'Progress' });
|
|
17
|
+
const current = list.querySelectorAll('[aria-current="step"]');
|
|
18
|
+
expect(current).toHaveLength(1);
|
|
19
|
+
expect(current[0]?.textContent).toContain('Profile');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('shows a check icon for completed steps and numbers for the rest', () => {
|
|
23
|
+
render(<Stepper steps={steps} currentStep={1} />);
|
|
24
|
+
const items = screen.getAllByRole('listitem');
|
|
25
|
+
expect(items[0]?.querySelector('svg')).not.toBeNull();
|
|
26
|
+
expect(items[1]?.querySelector('svg')).toBeNull();
|
|
27
|
+
expect(items[1]?.textContent).toContain('2');
|
|
28
|
+
expect(items[2]?.textContent).toContain('3');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('lets step.status override the derived status', () => {
|
|
32
|
+
render(<Stepper steps={[{ ...steps[0]!, status: 'error' }, steps[1]!]} currentStep={0} />);
|
|
33
|
+
const items = screen.getAllByRole('listitem');
|
|
34
|
+
expect(items[0]?.textContent).toContain('Account');
|
|
35
|
+
const dot = items[0]?.querySelector('span');
|
|
36
|
+
expect(dot?.className).toContain('border-destructive');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Stepper selection gating', () => {
|
|
41
|
+
it('fires onStepSelect only for completed steps by default', () => {
|
|
42
|
+
const onSelect = vi.fn();
|
|
43
|
+
render(<Stepper steps={steps} currentStep={2} onStepSelect={onSelect} />);
|
|
44
|
+
const buttons = screen.getAllByRole('button');
|
|
45
|
+
expect(buttons).toHaveLength(2);
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: /account/i }));
|
|
47
|
+
expect(onSelect).toHaveBeenCalledWith(0);
|
|
48
|
+
expect(screen.queryByRole('button', { name: /review/i })).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('canSelect gates forward jumps', () => {
|
|
52
|
+
const onSelect = vi.fn();
|
|
53
|
+
render(
|
|
54
|
+
<Stepper steps={steps} currentStep={0} onStepSelect={onSelect} canSelect={(i) => i <= 1} />,
|
|
55
|
+
);
|
|
56
|
+
fireEvent.click(screen.getByRole('button', { name: /profile/i }));
|
|
57
|
+
expect(onSelect).toHaveBeenCalledWith(1);
|
|
58
|
+
expect(screen.queryByRole('button', { name: /review/i })).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('locked steps never render as buttons', () => {
|
|
62
|
+
render(
|
|
63
|
+
<Stepper
|
|
64
|
+
steps={[steps[0]!, { ...steps[1]!, locked: true }, steps[2]!]}
|
|
65
|
+
currentStep={2}
|
|
66
|
+
onStepSelect={() => {}}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
expect(screen.queryByRole('button', { name: /profile/i })).toBeNull();
|
|
70
|
+
expect(screen.getByRole('button', { name: /account/i })).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders no buttons without onStepSelect', () => {
|
|
74
|
+
render(<Stepper steps={steps} currentStep={1} />);
|
|
75
|
+
expect(screen.queryAllByRole('button')).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Stepper orientation', () => {
|
|
80
|
+
it('stacks vertically with a rail segment between steps', () => {
|
|
81
|
+
const { container } = render(<Stepper steps={steps} currentStep={1} orientation="vertical" />);
|
|
82
|
+
const list = container.querySelector('ol');
|
|
83
|
+
expect(list?.className).toContain('flex-col');
|
|
84
|
+
const rail = container.querySelectorAll('ol > li > span.w-px');
|
|
85
|
+
expect(rail).toHaveLength(2);
|
|
86
|
+
expect(rail[0]?.className).toContain('bg-primary/60');
|
|
87
|
+
expect(rail[1]?.className).toContain('bg-border');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders descriptions and the optional marker', () => {
|
|
91
|
+
render(
|
|
92
|
+
<Stepper
|
|
93
|
+
steps={[{ id: 'a', label: 'Account', description: 'Your details', optional: true }]}
|
|
94
|
+
currentStep={0}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByText('Your details')).toBeTruthy();
|
|
98
|
+
expect(screen.getByText('(optional)')).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
DialogTitle,
|
|
16
16
|
} from './Dialog';
|
|
17
17
|
|
|
18
|
-
export type ModalSize = 'sm' | 'md' | 'lg';
|
|
18
|
+
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
19
19
|
|
|
20
20
|
export interface ModalProps {
|
|
21
21
|
open: boolean;
|
|
@@ -25,6 +25,7 @@ export interface ModalProps {
|
|
|
25
25
|
subtitle?: ReactNode;
|
|
26
26
|
size?: ModalSize;
|
|
27
27
|
danger?: boolean;
|
|
28
|
+
scrollable?: boolean;
|
|
28
29
|
footer?: ReactNode;
|
|
29
30
|
children?: ReactNode;
|
|
30
31
|
className?: string;
|
|
@@ -35,6 +36,8 @@ const sizeWidths: Record<ModalSize, string> = {
|
|
|
35
36
|
sm: 'max-w-sm',
|
|
36
37
|
md: 'max-w-lg',
|
|
37
38
|
lg: 'max-w-2xl',
|
|
39
|
+
xl: 'max-w-4xl',
|
|
40
|
+
'2xl': 'max-w-5xl',
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export function Modal({
|
|
@@ -45,6 +48,7 @@ export function Modal({
|
|
|
45
48
|
subtitle,
|
|
46
49
|
size = 'md',
|
|
47
50
|
danger = false,
|
|
51
|
+
scrollable = false,
|
|
48
52
|
footer,
|
|
49
53
|
children,
|
|
50
54
|
className,
|
|
@@ -52,7 +56,15 @@ export function Modal({
|
|
|
52
56
|
}: ModalProps) {
|
|
53
57
|
return (
|
|
54
58
|
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
55
|
-
<DialogContent
|
|
59
|
+
<DialogContent
|
|
60
|
+
className={cn(
|
|
61
|
+
sizeWidths[size],
|
|
62
|
+
danger && 'border-destructive',
|
|
63
|
+
scrollable && 'flex max-h-[92vh] flex-col overflow-hidden',
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
{...contentProps}
|
|
67
|
+
>
|
|
56
68
|
{(title !== undefined || description !== undefined || subtitle !== undefined) && (
|
|
57
69
|
<DialogHeader>
|
|
58
70
|
{title !== undefined && (
|
|
@@ -64,7 +76,7 @@ export function Modal({
|
|
|
64
76
|
{(subtitle ?? description) !== undefined && <DialogDescription>{subtitle ?? description}</DialogDescription>}
|
|
65
77
|
</DialogHeader>
|
|
66
78
|
)}
|
|
67
|
-
{children}
|
|
79
|
+
{scrollable ? <div className="min-h-0 flex-1 overflow-y-auto">{children}</div> : children}
|
|
68
80
|
{footer !== undefined && <DialogFooter>{footer}</DialogFooter>}
|
|
69
81
|
</DialogContent>
|
|
70
82
|
</Dialog>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlanPicker Component (Organism)
|
|
3
|
+
* @description Pricing plan card group with an optional billing cycle switch;
|
|
4
|
+
* money stays caller-computed — price and features are plain nodes
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { KeyboardEvent, ReactNode } from 'react';
|
|
8
|
+
import { Check } from 'lucide-react';
|
|
9
|
+
import { cn } from '../../infrastructure/utils';
|
|
10
|
+
import { Button } from '../atoms/Button';
|
|
11
|
+
import { Segmented } from '../molecules/Segmented';
|
|
12
|
+
|
|
13
|
+
export interface PlanPickerCycle<T extends string = string> {
|
|
14
|
+
value: T;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
hint?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PlanPickerPlan {
|
|
20
|
+
id: string;
|
|
21
|
+
name: ReactNode;
|
|
22
|
+
badge?: ReactNode;
|
|
23
|
+
description?: ReactNode;
|
|
24
|
+
price: ReactNode;
|
|
25
|
+
priceNote?: ReactNode;
|
|
26
|
+
meta?: ReactNode;
|
|
27
|
+
features?: ReactNode[];
|
|
28
|
+
current?: boolean;
|
|
29
|
+
highlight?: boolean;
|
|
30
|
+
ctaLabel?: ReactNode;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
onSelect?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PlanPickerProps<T extends string = string> {
|
|
36
|
+
plans: readonly PlanPickerPlan[];
|
|
37
|
+
cycle?: T;
|
|
38
|
+
cycles?: readonly PlanPickerCycle<T>[];
|
|
39
|
+
onCycleChange?: (value: T) => void;
|
|
40
|
+
selectedId?: string;
|
|
41
|
+
onPlanSelect?: (id: string) => void;
|
|
42
|
+
busy?: boolean;
|
|
43
|
+
columns?: 2 | 3 | 4;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gridColumns: Record<2 | 3 | 4, string> = {
|
|
48
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
49
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
50
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function PlanCard({
|
|
54
|
+
plan,
|
|
55
|
+
selected,
|
|
56
|
+
selectable,
|
|
57
|
+
busy,
|
|
58
|
+
onSelect,
|
|
59
|
+
}: {
|
|
60
|
+
plan: PlanPickerPlan;
|
|
61
|
+
selected: boolean;
|
|
62
|
+
selectable: boolean;
|
|
63
|
+
busy: boolean;
|
|
64
|
+
onSelect: (id: string) => void;
|
|
65
|
+
}) {
|
|
66
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
67
|
+
if (selectable && (event.key === 'Enter' || event.key === ' ')) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
onSelect(plan.id);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return (
|
|
73
|
+
<article
|
|
74
|
+
role={selectable ? 'radio' : undefined}
|
|
75
|
+
aria-checked={selectable ? selected : undefined}
|
|
76
|
+
tabIndex={selectable ? 0 : undefined}
|
|
77
|
+
onClick={() => selectable && onSelect(plan.id)}
|
|
78
|
+
onKeyDown={handleKeyDown}
|
|
79
|
+
className={cn(
|
|
80
|
+
'relative flex flex-col gap-4 rounded-xl border bg-card p-5 text-left transition',
|
|
81
|
+
plan.highlight ? 'border-2 border-primary shadow-lg shadow-primary/5' : 'border-border',
|
|
82
|
+
selected && !plan.highlight && 'border-primary bg-primary/5',
|
|
83
|
+
plan.current && 'bg-muted/30',
|
|
84
|
+
selectable && 'cursor-pointer hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{plan.badge !== undefined && (
|
|
88
|
+
<span
|
|
89
|
+
className={cn(
|
|
90
|
+
'absolute -top-2.5 left-1/2 -translate-x-1/2 rounded-full px-2.5 py-0.5 text-[11px] font-bold shadow-xs',
|
|
91
|
+
plan.highlight ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground',
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{plan.badge}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
<div className="flex flex-col gap-1">
|
|
98
|
+
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{plan.name}</span>
|
|
99
|
+
<span className="flex flex-wrap items-baseline gap-1">
|
|
100
|
+
<span className="text-3xl font-extrabold tracking-tight text-foreground">{plan.price}</span>
|
|
101
|
+
{plan.priceNote !== undefined && (
|
|
102
|
+
<span className="text-xs text-muted-foreground">{plan.priceNote}</span>
|
|
103
|
+
)}
|
|
104
|
+
</span>
|
|
105
|
+
{plan.description !== undefined && (
|
|
106
|
+
<p className="text-xs text-muted-foreground">{plan.description}</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
{plan.meta !== undefined && (
|
|
110
|
+
<div className="flex items-center gap-2 rounded-lg bg-muted/60 px-2.5 py-2 text-xs text-muted-foreground">
|
|
111
|
+
{plan.meta}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{plan.features !== undefined && plan.features.length > 0 && (
|
|
115
|
+
<ul className="flex flex-col gap-2 text-xs text-foreground/80">
|
|
116
|
+
{plan.features.map((feature, index) => (
|
|
117
|
+
<li key={index} className="flex items-start gap-2">
|
|
118
|
+
<Check className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
|
|
119
|
+
<span>{feature}</span>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
)}
|
|
124
|
+
<div className="mt-auto pt-1">
|
|
125
|
+
{plan.current ? (
|
|
126
|
+
<div
|
|
127
|
+
aria-disabled="true"
|
|
128
|
+
className="w-full rounded-lg bg-success/10 px-3 py-2 text-center text-xs font-semibold text-success"
|
|
129
|
+
>
|
|
130
|
+
{plan.ctaLabel ?? 'Current plan'}
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
<Button
|
|
134
|
+
variant={plan.highlight ? 'primary' : 'secondary'}
|
|
135
|
+
size="sm"
|
|
136
|
+
block
|
|
137
|
+
disabled={plan.disabled}
|
|
138
|
+
loading={busy}
|
|
139
|
+
onClick={(event) => {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
plan.onSelect?.();
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{plan.ctaLabel ?? 'Select'}
|
|
145
|
+
</Button>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</article>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function PlanPicker<T extends string = string>({
|
|
153
|
+
plans,
|
|
154
|
+
cycle,
|
|
155
|
+
cycles,
|
|
156
|
+
onCycleChange,
|
|
157
|
+
selectedId,
|
|
158
|
+
onPlanSelect,
|
|
159
|
+
busy = false,
|
|
160
|
+
columns = 3,
|
|
161
|
+
className,
|
|
162
|
+
}: PlanPickerProps<T>) {
|
|
163
|
+
const showCycles = cycles !== undefined && cycles.length > 0 && cycle !== undefined && onCycleChange !== undefined;
|
|
164
|
+
return (
|
|
165
|
+
<div className={className}>
|
|
166
|
+
{showCycles && (
|
|
167
|
+
<div className="mb-5 flex justify-center">
|
|
168
|
+
<Segmented
|
|
169
|
+
aria-label="Billing cycle"
|
|
170
|
+
options={cycles.map((entry) => ({
|
|
171
|
+
value: entry.value,
|
|
172
|
+
label: (
|
|
173
|
+
<span className="flex items-center gap-1.5">
|
|
174
|
+
{entry.label}
|
|
175
|
+
{entry.hint !== undefined && (
|
|
176
|
+
<span className="rounded bg-success/15 px-1 py-0.5 text-[10px] font-bold text-success">
|
|
177
|
+
{entry.hint}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</span>
|
|
181
|
+
),
|
|
182
|
+
}))}
|
|
183
|
+
value={cycle}
|
|
184
|
+
onChange={onCycleChange}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div className={cn('grid gap-4', gridColumns[columns])}>
|
|
189
|
+
{plans.map((plan) => (
|
|
190
|
+
<PlanCard
|
|
191
|
+
key={plan.id}
|
|
192
|
+
plan={plan}
|
|
193
|
+
selected={selectedId === plan.id}
|
|
194
|
+
selectable={onPlanSelect !== undefined && !plan.current && !plan.disabled}
|
|
195
|
+
busy={busy}
|
|
196
|
+
onSelect={onPlanSelect ?? (() => {})}
|
|
197
|
+
/>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stepper Component (Organism)
|
|
3
|
+
* @description Horizontal or vertical step indicator with derived status,
|
|
4
|
+
* optional click navigation and per-step override/lock control
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
import { Check } from 'lucide-react';
|
|
9
|
+
import { cn } from '../../infrastructure/utils';
|
|
10
|
+
|
|
11
|
+
export type StepperStepStatus = 'completed' | 'current' | 'upcoming' | 'error';
|
|
12
|
+
|
|
13
|
+
export interface StepperStep {
|
|
14
|
+
id: string;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
description?: ReactNode;
|
|
17
|
+
icon?: ReactNode;
|
|
18
|
+
status?: StepperStepStatus;
|
|
19
|
+
locked?: boolean;
|
|
20
|
+
optional?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StepperProps {
|
|
24
|
+
steps: readonly StepperStep[];
|
|
25
|
+
currentStep: number;
|
|
26
|
+
orientation?: 'horizontal' | 'vertical';
|
|
27
|
+
onStepSelect?: (index: number) => void;
|
|
28
|
+
canSelect?: (index: number) => boolean;
|
|
29
|
+
ariaLabel?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deriveStatus(step: StepperStep, index: number, currentStep: number): StepperStepStatus {
|
|
34
|
+
if (step.status !== undefined) return step.status;
|
|
35
|
+
if (index < currentStep) return 'completed';
|
|
36
|
+
if (index === currentStep) return 'current';
|
|
37
|
+
return 'upcoming';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dotClasses: Record<StepperStepStatus, string> = {
|
|
41
|
+
completed: 'flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground',
|
|
42
|
+
current: 'flex size-7 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-background text-primary ring-4 ring-primary/15',
|
|
43
|
+
error: 'flex size-7 shrink-0 items-center justify-center rounded-full border-2 border-destructive bg-background text-destructive ring-4 ring-destructive/15',
|
|
44
|
+
upcoming: 'flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-muted-foreground',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const labelClasses: Record<StepperStepStatus, string> = {
|
|
48
|
+
completed: 'text-xs font-semibold text-foreground',
|
|
49
|
+
current: 'text-xs font-bold text-foreground',
|
|
50
|
+
error: 'text-xs font-bold text-destructive',
|
|
51
|
+
upcoming: 'text-xs font-medium text-muted-foreground',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function StepperNode({ step, index, status }: { step: StepperStep; index: number; status: StepperStepStatus }) {
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<span className={dotClasses[status]}>
|
|
58
|
+
{status === 'completed' && step.icon === undefined ? (
|
|
59
|
+
<Check className="size-4" aria-hidden="true" />
|
|
60
|
+
) : (
|
|
61
|
+
step.icon ?? <span className="text-[11px] font-bold">{index + 1}</span>
|
|
62
|
+
)}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="flex min-w-0 flex-col gap-0.5 text-left">
|
|
65
|
+
<span className={cn(labelClasses[status], 'flex items-center gap-1.5')}>
|
|
66
|
+
{step.label}
|
|
67
|
+
{step.optional && <span className="font-normal text-muted-foreground">(optional)</span>}
|
|
68
|
+
</span>
|
|
69
|
+
{step.description !== undefined && (
|
|
70
|
+
<span className="text-[11px] leading-snug text-muted-foreground">{step.description}</span>
|
|
71
|
+
)}
|
|
72
|
+
</span>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Stepper({
|
|
78
|
+
steps,
|
|
79
|
+
currentStep,
|
|
80
|
+
orientation = 'horizontal',
|
|
81
|
+
onStepSelect,
|
|
82
|
+
canSelect,
|
|
83
|
+
ariaLabel,
|
|
84
|
+
className,
|
|
85
|
+
}: StepperProps) {
|
|
86
|
+
const interactive = onStepSelect !== undefined;
|
|
87
|
+
return (
|
|
88
|
+
<ol
|
|
89
|
+
aria-label={ariaLabel}
|
|
90
|
+
className={cn(
|
|
91
|
+
orientation === 'horizontal' ? 'flex w-full items-start gap-2' : 'flex w-full flex-col',
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{steps.map((step, index) => {
|
|
96
|
+
const status = deriveStatus(step, index, currentStep);
|
|
97
|
+
const clickable =
|
|
98
|
+
interactive && !step.locked && (canSelect ? canSelect(index) : index < currentStep);
|
|
99
|
+
const last = index === steps.length - 1;
|
|
100
|
+
return (
|
|
101
|
+
<li
|
|
102
|
+
key={step.id}
|
|
103
|
+
aria-current={status === 'current' ? 'step' : undefined}
|
|
104
|
+
className={cn(
|
|
105
|
+
orientation === 'horizontal'
|
|
106
|
+
? 'flex min-w-0 flex-1 items-center gap-2'
|
|
107
|
+
: cn('relative flex items-start gap-3', !last && 'pb-5'),
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{orientation === 'vertical' && !last && (
|
|
111
|
+
<span
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
className={cn(
|
|
114
|
+
'absolute bottom-1 left-3.5 top-9 w-px',
|
|
115
|
+
status === 'completed' ? 'bg-primary/60' : 'bg-border',
|
|
116
|
+
)}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
{clickable ? (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => onStepSelect?.(index)}
|
|
123
|
+
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md p-0.5 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
124
|
+
>
|
|
125
|
+
<StepperNode step={step} index={index} status={status} />
|
|
126
|
+
</button>
|
|
127
|
+
) : (
|
|
128
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
129
|
+
<StepperNode step={step} index={index} status={status} />
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
{orientation === 'horizontal' && !last && (
|
|
133
|
+
<span
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
className={cn(
|
|
136
|
+
'hidden h-px min-w-4 flex-1 sm:block',
|
|
137
|
+
status === 'completed' ? 'bg-primary/60' : 'bg-border',
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -177,14 +177,12 @@ export type {
|
|
|
177
177
|
|
|
178
178
|
export { ConfirmDialog } from './ConfirmDialog';
|
|
179
179
|
export type { ConfirmDialogProps } from './ConfirmDialog';
|
|
180
|
-
|
|
181
|
-
export
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
CalendarView,
|
|
187
|
-
} from './CalendarBoard';
|
|
180
|
+
|
|
181
|
+
export { Stepper } from './Stepper';
|
|
182
|
+
export type { StepperProps, StepperStep, StepperStepStatus } from './Stepper';
|
|
183
|
+
|
|
184
|
+
export { PlanPicker } from './PlanPicker';
|
|
185
|
+
export type { PlanPickerProps, PlanPickerPlan, PlanPickerCycle } from './PlanPicker';
|
|
188
186
|
|
|
189
187
|
export { CoverArt } from './CoverArt';
|
|
190
188
|
export type { CoverArtProps } from './CoverArt';
|