@dilipod/ui 0.4.2 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -46,6 +46,9 @@
46
46
  "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap",
47
47
  "dev": "tsup --watch",
48
48
  "lint": "eslint src/",
49
+ "test": "vitest",
50
+ "test:run": "vitest run",
51
+ "test:coverage": "vitest run --coverage",
49
52
  "storybook": "storybook dev -p 6006",
50
53
  "build-storybook": "storybook build",
51
54
  "clean": "rm -rf dist",
@@ -89,9 +92,14 @@
89
92
  "@storybook/react": "8.6.14",
90
93
  "@storybook/react-vite": "8.6.14",
91
94
  "@storybook/test": "8.6.14",
95
+ "@testing-library/jest-dom": "^6.0.0",
96
+ "@testing-library/react": "^16.0.0",
97
+ "@testing-library/user-event": "^14.0.0",
92
98
  "@types/react": "^19.0.0",
93
99
  "@types/react-dom": "^19.0.0",
100
+ "@vitejs/plugin-react": "^4.2.0",
94
101
  "autoprefixer": "^10.4.21",
102
+ "jsdom": "^23.0.0",
95
103
  "playwright": "^1.57.0",
96
104
  "postcss": "^8.5.4",
97
105
  "react": "^19.0.0",
@@ -99,6 +107,8 @@
99
107
  "storybook": "8.6.14",
100
108
  "tailwindcss": "^4.1.8",
101
109
  "tsup": "^8.5.0",
102
- "typescript": "^5.8.3"
110
+ "typescript": "^5.8.3",
111
+ "vite": "^7.3.1",
112
+ "vitest": "^1.2.0"
103
113
  }
104
114
  }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Button, buttonVariants } from '../components/button'
4
+
5
+ describe('Button', () => {
6
+ describe('Rendering', () => {
7
+ it('should render button with text', () => {
8
+ render(<Button>Click me</Button>)
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
10
+ })
11
+
12
+ it('should support custom HTML attributes', () => {
13
+ render(<Button data-testid="custom-button">Custom</Button>)
14
+ expect(screen.getByTestId('custom-button')).toBeInTheDocument()
15
+ })
16
+ })
17
+
18
+ describe('Variants', () => {
19
+ it('should apply default variant styles', () => {
20
+ render(<Button>Default</Button>)
21
+ const button = screen.getByRole('button')
22
+ expect(button.className).toContain('bg-')
23
+ })
24
+
25
+ it('should apply outline variant styles', () => {
26
+ render(<Button variant="outline">Outline</Button>)
27
+ const button = screen.getByRole('button')
28
+ expect(button.className).toContain('border')
29
+ })
30
+
31
+ it('should apply ghost variant styles', () => {
32
+ render(<Button variant="ghost">Ghost</Button>)
33
+ const button = screen.getByRole('button')
34
+ expect(button.className).toContain('hover:bg-')
35
+ })
36
+
37
+ it('should apply destructive variant styles', () => {
38
+ render(<Button variant="destructive">Delete</Button>)
39
+ const button = screen.getByRole('button')
40
+ // Destructive uses red colors
41
+ expect(button.className).toContain('red')
42
+ })
43
+
44
+ it('should apply link variant styles', () => {
45
+ render(<Button variant="link">Link</Button>)
46
+ const button = screen.getByRole('button')
47
+ expect(button.className).toContain('underline')
48
+ })
49
+ })
50
+
51
+ describe('Sizes', () => {
52
+ it('should apply default size', () => {
53
+ render(<Button>Default Size</Button>)
54
+ const button = screen.getByRole('button')
55
+ expect(button.className).toContain('h-')
56
+ })
57
+
58
+ it('should apply small size', () => {
59
+ render(<Button size="sm">Small</Button>)
60
+ const button = screen.getByRole('button')
61
+ expect(button.className).toContain('h-')
62
+ })
63
+
64
+ it('should apply large size', () => {
65
+ render(<Button size="lg">Large</Button>)
66
+ const button = screen.getByRole('button')
67
+ expect(button.className).toContain('h-')
68
+ })
69
+
70
+ it('should apply icon size', () => {
71
+ render(<Button size="icon">🔍</Button>)
72
+ const button = screen.getByRole('button')
73
+ expect(button.className).toContain('w-')
74
+ })
75
+ })
76
+
77
+ describe('States', () => {
78
+ it('should be disabled when disabled prop is true', () => {
79
+ render(<Button disabled>Disabled</Button>)
80
+ expect(screen.getByRole('button')).toBeDisabled()
81
+ })
82
+
83
+ it('should show loading state', () => {
84
+ render(<Button loading>Loading</Button>)
85
+ const button = screen.getByRole('button')
86
+ expect(button).toBeDisabled()
87
+ // Should have loading spinner
88
+ expect(button.querySelector('.animate-spin')).toBeInTheDocument()
89
+ })
90
+
91
+ it('should not be clickable when loading', () => {
92
+ const onClick = vi.fn()
93
+ render(<Button loading onClick={onClick}>Loading</Button>)
94
+
95
+ fireEvent.click(screen.getByRole('button'))
96
+ expect(onClick).not.toHaveBeenCalled()
97
+ })
98
+ })
99
+
100
+ describe('Events', () => {
101
+ it('should call onClick when clicked', () => {
102
+ const onClick = vi.fn()
103
+ render(<Button onClick={onClick}>Click</Button>)
104
+
105
+ fireEvent.click(screen.getByRole('button'))
106
+ expect(onClick).toHaveBeenCalledTimes(1)
107
+ })
108
+
109
+ it('should not call onClick when disabled', () => {
110
+ const onClick = vi.fn()
111
+ render(<Button disabled onClick={onClick}>Disabled</Button>)
112
+
113
+ fireEvent.click(screen.getByRole('button'))
114
+ expect(onClick).not.toHaveBeenCalled()
115
+ })
116
+ })
117
+
118
+ describe('Custom className', () => {
119
+ it('should merge custom className with default styles', () => {
120
+ render(<Button className="custom-class">Custom</Button>)
121
+ const button = screen.getByRole('button')
122
+ expect(button.className).toContain('custom-class')
123
+ })
124
+ })
125
+
126
+ describe('Button type', () => {
127
+ it('should support type="submit"', () => {
128
+ render(<Button type="submit">Submit</Button>)
129
+ expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
130
+ })
131
+
132
+ it('should support type="button" explicitly', () => {
133
+ render(<Button type="button">Button</Button>)
134
+ expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
135
+ })
136
+ })
137
+
138
+ describe('buttonVariants helper', () => {
139
+ it('should generate correct class names', () => {
140
+ const classes = buttonVariants({ variant: 'default', size: 'default' })
141
+ expect(typeof classes).toBe('string')
142
+ expect(classes.length).toBeGreaterThan(0)
143
+ })
144
+
145
+ it('should handle different combinations', () => {
146
+ const combinations = [
147
+ { variant: 'default' as const, size: 'sm' as const },
148
+ { variant: 'outline' as const, size: 'lg' as const },
149
+ { variant: 'ghost' as const, size: 'icon' as const },
150
+ ]
151
+
152
+ combinations.forEach(combo => {
153
+ const classes = buttonVariants(combo)
154
+ expect(typeof classes).toBe('string')
155
+ })
156
+ })
157
+ })
158
+
159
+ describe('Accessibility', () => {
160
+ it('should support aria-label', () => {
161
+ render(<Button aria-label="Close dialog">×</Button>)
162
+ expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument()
163
+ })
164
+
165
+ it('should support aria-disabled when disabled', () => {
166
+ render(<Button disabled>Disabled</Button>)
167
+ const button = screen.getByRole('button')
168
+ expect(button).toHaveAttribute('disabled')
169
+ })
170
+ })
171
+ })
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { ScenariosManager, type Scenario, type ScenarioType } from '../components/scenarios-manager'
5
+
6
+ describe('ScenariosManager', () => {
7
+ const mockOnAdd = vi.fn()
8
+ const mockOnUpdate = vi.fn()
9
+ const mockOnDelete = vi.fn()
10
+ const mockOnComplete = vi.fn()
11
+
12
+ const defaultProps = {
13
+ scenarios: [],
14
+ onAdd: mockOnAdd,
15
+ onUpdate: mockOnUpdate,
16
+ onDelete: mockOnDelete,
17
+ }
18
+
19
+ const mockScenarios: Scenario[] = [
20
+ {
21
+ id: '1',
22
+ type: 'default_behavior' as ScenarioType,
23
+ situation: 'Invoice amount is over $10,000',
24
+ action: 'Flag for manual review',
25
+ },
26
+ {
27
+ id: '2',
28
+ type: 'escalation' as ScenarioType,
29
+ situation: 'Email is marked as spam',
30
+ action: 'Ignore and archive',
31
+ },
32
+ ]
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks()
36
+ mockOnAdd.mockResolvedValue(undefined)
37
+ mockOnUpdate.mockResolvedValue(undefined)
38
+ mockOnDelete.mockResolvedValue(undefined)
39
+ mockOnComplete.mockResolvedValue(undefined)
40
+ })
41
+
42
+ describe('Empty State', () => {
43
+ it('should show empty state when no scenarios exist', () => {
44
+ render(<ScenariosManager {...defaultProps} />)
45
+
46
+ expect(screen.getByText('No scenarios yet. Add rules for how the worker should handle edge cases.')).toBeInTheDocument()
47
+ })
48
+
49
+ it('should show "Add your first scenario" button in empty state', () => {
50
+ render(<ScenariosManager {...defaultProps} />)
51
+
52
+ expect(screen.getByText('Add your first scenario')).toBeInTheDocument()
53
+ })
54
+ })
55
+
56
+ describe('Displaying Scenarios', () => {
57
+ it('should render list of scenarios', () => {
58
+ render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
59
+
60
+ expect(screen.getByText('Invoice amount is over $10,000')).toBeInTheDocument()
61
+ expect(screen.getByText('Email is marked as spam')).toBeInTheDocument()
62
+ })
63
+
64
+ it('should show scenario type badges', () => {
65
+ render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
66
+
67
+ expect(screen.getByText('Handle it')).toBeInTheDocument()
68
+ expect(screen.getByText('Ask me first')).toBeInTheDocument()
69
+ })
70
+
71
+ it('should display When and Action for each scenario', () => {
72
+ render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
73
+
74
+ const whenLabels = screen.getAllByText(/When:/)
75
+ const actionLabels = screen.getAllByText(/Action:/)
76
+ expect(whenLabels.length).toBe(2)
77
+ expect(actionLabels.length).toBe(2)
78
+ })
79
+ })
80
+
81
+ describe('Adding Scenarios', () => {
82
+ it('should show add scenario modal when clicking add button', async () => {
83
+ render(<ScenariosManager {...defaultProps} />)
84
+
85
+ await userEvent.click(screen.getByText('Add your first scenario'))
86
+
87
+ // Modal should appear with a dialog role
88
+ await waitFor(() => {
89
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
90
+ })
91
+ })
92
+
93
+ it('should close modal when clicking close', async () => {
94
+ render(<ScenariosManager {...defaultProps} />)
95
+
96
+ await userEvent.click(screen.getByText('Add your first scenario'))
97
+
98
+ await waitFor(() => {
99
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
100
+ })
101
+
102
+ // Cancel should close the modal
103
+ const cancelButton = screen.getByText('Cancel')
104
+ await userEvent.click(cancelButton)
105
+
106
+ await waitFor(() => {
107
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
108
+ })
109
+ })
110
+
111
+ it('should open add modal dialog', async () => {
112
+ render(<ScenariosManager {...defaultProps} />)
113
+
114
+ await userEvent.click(screen.getByText('Add your first scenario'))
115
+
116
+ // Should show modal (dialog role)
117
+ await waitFor(() => {
118
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
119
+ })
120
+ })
121
+ })
122
+
123
+ describe('Deleting Scenarios', () => {
124
+ it('should have delete buttons for each scenario', () => {
125
+ render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
126
+
127
+ // Should render multiple buttons (edit + delete for each scenario)
128
+ const allButtons = screen.getAllByRole('button')
129
+ // At minimum: add button + (edit + delete) for each scenario = 1 + 4 = 5
130
+ expect(allButtons.length).toBeGreaterThanOrEqual(4)
131
+ })
132
+ })
133
+
134
+ describe('Completion Feature', () => {
135
+ it('should not show complete button when below minimum scenarios', () => {
136
+ render(
137
+ <ScenariosManager
138
+ {...defaultProps}
139
+ scenarios={[mockScenarios[0]]}
140
+ onComplete={mockOnComplete}
141
+ minScenariosToComplete={2}
142
+ />
143
+ )
144
+
145
+ expect(screen.queryByText('Mark complete')).not.toBeInTheDocument()
146
+ })
147
+
148
+ it('should show complete button when minimum scenarios are added', () => {
149
+ render(
150
+ <ScenariosManager
151
+ {...defaultProps}
152
+ scenarios={mockScenarios}
153
+ onComplete={mockOnComplete}
154
+ minScenariosToComplete={2}
155
+ />
156
+ )
157
+
158
+ expect(screen.getByText('Mark complete')).toBeInTheDocument()
159
+ })
160
+
161
+ it('should call onComplete when clicking complete button', async () => {
162
+ render(
163
+ <ScenariosManager
164
+ {...defaultProps}
165
+ scenarios={mockScenarios}
166
+ onComplete={mockOnComplete}
167
+ minScenariosToComplete={1}
168
+ />
169
+ )
170
+
171
+ await userEvent.click(screen.getByText('Mark complete'))
172
+
173
+ await waitFor(() => {
174
+ expect(mockOnComplete).toHaveBeenCalled()
175
+ })
176
+ })
177
+
178
+ it('should show completed state when isComplete is true', () => {
179
+ render(
180
+ <ScenariosManager
181
+ {...defaultProps}
182
+ scenarios={mockScenarios}
183
+ isComplete={true}
184
+ />
185
+ )
186
+
187
+ expect(screen.getByText('Scenarios completed')).toBeInTheDocument()
188
+ })
189
+
190
+ it('should not show complete button when already complete', () => {
191
+ render(
192
+ <ScenariosManager
193
+ {...defaultProps}
194
+ scenarios={mockScenarios}
195
+ onComplete={mockOnComplete}
196
+ isComplete={true}
197
+ minScenariosToComplete={1}
198
+ />
199
+ )
200
+
201
+ expect(screen.queryByText('Mark complete')).not.toBeInTheDocument()
202
+ })
203
+ })
204
+
205
+ describe('Suggestions', () => {
206
+ const suggestions = [
207
+ { situation: 'Invoice is missing PO number', type: 'default_behavior' as ScenarioType, action: 'Request PO' },
208
+ { situation: 'Duplicate invoice detected', type: 'escalation' as ScenarioType, action: 'Flag for review' },
209
+ ]
210
+
211
+ it('should display scenario suggestions when provided', () => {
212
+ render(
213
+ <ScenariosManager
214
+ {...defaultProps}
215
+ suggestions={suggestions}
216
+ />
217
+ )
218
+
219
+ expect(screen.getByText('Invoice is missing PO number')).toBeInTheDocument()
220
+ expect(screen.getByText('Duplicate invoice detected')).toBeInTheDocument()
221
+ })
222
+
223
+ it('should render suggestion buttons', () => {
224
+ render(
225
+ <ScenariosManager
226
+ {...defaultProps}
227
+ suggestions={suggestions}
228
+ />
229
+ )
230
+
231
+ // Suggestions should be clickable buttons
232
+ const suggestionButton = screen.getByText('Invoice is missing PO number').closest('button')
233
+ expect(suggestionButton).toBeInTheDocument()
234
+ })
235
+ })
236
+
237
+ describe('Loading State', () => {
238
+ it('should render with isLoading prop', () => {
239
+ render(
240
+ <ScenariosManager
241
+ {...defaultProps}
242
+ isLoading={true}
243
+ />
244
+ )
245
+
246
+ // Component should still render with loading state
247
+ expect(screen.getByText('Add your first scenario')).toBeInTheDocument()
248
+ })
249
+ })
250
+ })
251
+
252
+ describe('Scenario Type Configuration', () => {
253
+ it('should have all four scenario types', () => {
254
+ const types: ScenarioType[] = ['escalation', 'default_behavior', 'quality_check', 'edge_case']
255
+
256
+ types.forEach(type => {
257
+ expect(types).toContain(type)
258
+ })
259
+ })
260
+ })
@@ -0,0 +1,31 @@
1
+ import '@testing-library/jest-dom'
2
+ import { vi } from 'vitest'
3
+
4
+ // Mock ResizeObserver
5
+ global.ResizeObserver = vi.fn().mockImplementation(() => ({
6
+ observe: vi.fn(),
7
+ unobserve: vi.fn(),
8
+ disconnect: vi.fn(),
9
+ }))
10
+
11
+ // Mock IntersectionObserver
12
+ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
13
+ observe: vi.fn(),
14
+ unobserve: vi.fn(),
15
+ disconnect: vi.fn(),
16
+ }))
17
+
18
+ // Mock matchMedia
19
+ Object.defineProperty(window, 'matchMedia', {
20
+ writable: true,
21
+ value: vi.fn().mockImplementation((query) => ({
22
+ matches: false,
23
+ media: query,
24
+ onchange: null,
25
+ addListener: vi.fn(),
26
+ removeListener: vi.fn(),
27
+ addEventListener: vi.fn(),
28
+ removeEventListener: vi.fn(),
29
+ dispatchEvent: vi.fn(),
30
+ })),
31
+ })
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { Slot } from '@radix-ui/react-slot'
4
+ import { Slot, Slottable } from '@radix-ui/react-slot'
5
5
  import { cva, type VariantProps } from 'class-variance-authority'
6
6
 
7
7
  import { cn } from '../lib/utils'
@@ -14,7 +14,7 @@ const buttonVariants = cva(
14
14
  default:
15
15
  'bg-[var(--black)] text-white border-2 border-[var(--black)] hover:bg-gray-800 hover:border-gray-800 active:scale-95',
16
16
  primary:
17
- 'bg-[var(--cyan)] text-[var(--black)] border-2 border-[var(--cyan)] hover:bg-[var(--cyan-dark)] hover:border-[var(--cyan-dark)] active:scale-95',
17
+ 'bg-[var(--cyan)] text-white border-2 border-[var(--cyan)] hover:bg-[var(--cyan-dark)] hover:border-[var(--cyan-dark)] active:scale-95',
18
18
  destructive:
19
19
  'bg-red-600 text-white border-2 border-red-600 hover:bg-red-700 hover:border-red-700 active:scale-95',
20
20
  outline:
@@ -48,13 +48,57 @@ export interface ButtonProps
48
48
  loading?: boolean
49
49
  /** Loading text (defaults to current children text) */
50
50
  loadingText?: string
51
+ /** Icon to show before the text */
52
+ icon?: React.ReactNode
53
+ /** Icon to show after the text */
54
+ iconAfter?: React.ReactNode
51
55
  }
52
56
 
53
57
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
54
- ({ className, variant, size, asChild = false, loading, loadingText, children, disabled, ...props }, ref) => {
58
+ ({ className, variant, size, asChild = false, loading, loadingText, icon, iconAfter, children, disabled, ...props }, ref) => {
55
59
  const Comp = asChild ? Slot : 'button'
56
60
  const isDisabled = disabled || loading
57
61
 
62
+ // Loading spinner component
63
+ const LoadingSpinner = (
64
+ <svg
65
+ className="animate-spin h-4 w-4"
66
+ xmlns="http://www.w3.org/2000/svg"
67
+ fill="none"
68
+ viewBox="0 0 24 24"
69
+ >
70
+ <circle
71
+ className="opacity-25"
72
+ cx="12"
73
+ cy="12"
74
+ r="10"
75
+ stroke="currentColor"
76
+ strokeWidth="4"
77
+ />
78
+ <path
79
+ className="opacity-75"
80
+ fill="currentColor"
81
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
82
+ />
83
+ </svg>
84
+ )
85
+
86
+ // When using asChild with icons, we need to use Slottable
87
+ if (asChild && (icon || iconAfter || loading)) {
88
+ return (
89
+ <Slot
90
+ className={cn(buttonVariants({ variant, size, className }))}
91
+ ref={ref}
92
+ {...props}
93
+ >
94
+ {loading && LoadingSpinner}
95
+ {!loading && icon}
96
+ <Slottable>{children}</Slottable>
97
+ {!loading && iconAfter}
98
+ </Slot>
99
+ )
100
+ }
101
+
58
102
  return (
59
103
  <Comp
60
104
  className={cn(buttonVariants({ variant, size, className }))}
@@ -62,29 +106,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
62
106
  disabled={isDisabled}
63
107
  {...props}
64
108
  >
65
- {loading && (
66
- <svg
67
- className="animate-spin h-4 w-4"
68
- xmlns="http://www.w3.org/2000/svg"
69
- fill="none"
70
- viewBox="0 0 24 24"
71
- >
72
- <circle
73
- className="opacity-25"
74
- cx="12"
75
- cy="12"
76
- r="10"
77
- stroke="currentColor"
78
- strokeWidth="4"
79
- />
80
- <path
81
- className="opacity-75"
82
- fill="currentColor"
83
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
84
- />
85
- </svg>
86
- )}
109
+ {loading && LoadingSpinner}
110
+ {!loading && icon}
87
111
  {loading ? loadingText || children : children}
112
+ {!loading && iconAfter}
88
113
  </Comp>
89
114
  )
90
115
  }