@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.
@@ -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 className={cn(sizeWidths[size], danger && 'border-destructive', className)} {...contentProps}>
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
- export { CalendarBoard } from './CalendarBoard';
181
- export type {
182
- CalendarBoardProps,
183
- CalendarEvent,
184
- CalendarEventTone,
185
- CalendarLegendItem,
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';