@aiready/components 0.13.18 → 0.13.20
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/charts/ForceDirectedGraph.d.ts +7 -13
- package/dist/charts/ForceDirectedGraph.js +451 -337
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +728 -586
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/charts/ForceDirectedGraph.tsx +12 -509
- package/src/charts/LinkItem.tsx +1 -1
- package/src/charts/NodeItem.tsx +1 -1
- package/src/charts/force-directed/ControlButton.tsx +39 -0
- package/src/charts/force-directed/ForceDirectedGraph.tsx +250 -0
- package/src/charts/force-directed/GraphCanvas.tsx +129 -0
- package/src/charts/{GraphControls.tsx → force-directed/GraphControls.tsx} +3 -110
- package/src/charts/force-directed/index.ts +31 -0
- package/src/charts/force-directed/types.ts +102 -0
- package/src/charts/{hooks.ts → force-directed/useGraphInteractions.ts} +64 -1
- package/src/charts/force-directed/useGraphLayout.ts +54 -0
- package/src/charts/force-directed/useImperativeHandle.ts +131 -0
- package/src/charts/layout-utils.ts +1 -1
- package/src/components/feedback/__tests__/badge.test.tsx +92 -0
- package/src/components/ui/__tests__/button.test.tsx +203 -0
- package/src/data-display/__tests__/ScoreBar.test.tsx +215 -0
- package/src/index.ts +4 -1
- package/src/utils/__tests__/score.test.ts +28 -7
- package/src/utils/score.ts +67 -29
- package/src/charts/types.ts +0 -24
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { useCallback, useEffect } from 'react';
|
|
2
2
|
import * as d3 from 'd3';
|
|
3
3
|
import { GraphNode } from './types';
|
|
4
4
|
|
|
5
|
+
/** Pins a node to its current position (sets fx/fy to current x/y) */
|
|
6
|
+
export function pinNode(node: GraphNode): void {
|
|
7
|
+
node.fx = node.x;
|
|
8
|
+
node.fy = node.y;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Unpins a node (sets fx/fy to null) */
|
|
12
|
+
export function unpinNode(node: GraphNode): void {
|
|
13
|
+
node.fx = null;
|
|
14
|
+
node.fy = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Unpins all nodes - helper for bulk unpin operations */
|
|
18
|
+
export function unpinAllNodes(nodes: GraphNode[]): void {
|
|
19
|
+
nodes.forEach(unpinNode);
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/**
|
|
6
23
|
* Hook for managing D3 zoom behavior on an SVG element.
|
|
7
24
|
*/
|
|
@@ -85,3 +102,49 @@ export function useWindowDrag(
|
|
|
85
102
|
};
|
|
86
103
|
}, [enableDrag, svgRef, transformRef, dragActiveRef, dragNodeRef, onDragEnd]);
|
|
87
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Hook for managing node interactions (drag, double-click pinning).
|
|
108
|
+
*/
|
|
109
|
+
export function useNodeInteractions(
|
|
110
|
+
enableDrag: boolean,
|
|
111
|
+
_nodes: GraphNode[],
|
|
112
|
+
_pinnedNodes: Set<string>,
|
|
113
|
+
setPinnedNodes: React.Dispatch<React.SetStateAction<Set<string>>>,
|
|
114
|
+
restart: () => void,
|
|
115
|
+
stop: () => void
|
|
116
|
+
) {
|
|
117
|
+
const handleDragStart = useCallback(
|
|
118
|
+
(event: React.MouseEvent, node: GraphNode) => {
|
|
119
|
+
if (!enableDrag) return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
event.stopPropagation();
|
|
122
|
+
pinNode(node);
|
|
123
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
124
|
+
stop();
|
|
125
|
+
},
|
|
126
|
+
[enableDrag, stop, setPinnedNodes]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const handleNodeDoubleClick = useCallback(
|
|
130
|
+
(event: React.MouseEvent, node: GraphNode) => {
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
if (!enableDrag) return;
|
|
133
|
+
if (node.fx === null || node.fx === undefined) {
|
|
134
|
+
pinNode(node);
|
|
135
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
136
|
+
} else {
|
|
137
|
+
unpinNode(node);
|
|
138
|
+
setPinnedNodes((prev) => {
|
|
139
|
+
const next = new Set(prev);
|
|
140
|
+
next.delete(node.id);
|
|
141
|
+
return next;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
restart();
|
|
145
|
+
},
|
|
146
|
+
[enableDrag, restart, setPinnedNodes]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return { handleDragStart, handleNodeDoubleClick };
|
|
150
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import { GraphNode, LayoutType } from './types';
|
|
3
|
+
import {
|
|
4
|
+
applyCircularLayout,
|
|
5
|
+
applyHierarchicalLayout,
|
|
6
|
+
applyInitialForceLayout,
|
|
7
|
+
} from '../layout-utils';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hook for managing graph layout algorithms.
|
|
11
|
+
*/
|
|
12
|
+
export function useGraphLayout(
|
|
13
|
+
initialNodes: GraphNode[],
|
|
14
|
+
width: number,
|
|
15
|
+
height: number,
|
|
16
|
+
layout: LayoutType,
|
|
17
|
+
restart: () => void
|
|
18
|
+
) {
|
|
19
|
+
// Initial positioning - delegate to layout utils
|
|
20
|
+
const nodes = useMemo(() => {
|
|
21
|
+
if (!initialNodes || !initialNodes.length) return initialNodes;
|
|
22
|
+
const copy = initialNodes.map((n) => ({ ...n }));
|
|
23
|
+
if (layout === 'circular') applyCircularLayout(copy, width, height);
|
|
24
|
+
else if (layout === 'hierarchical')
|
|
25
|
+
applyHierarchicalLayout(copy, width, height);
|
|
26
|
+
else applyInitialForceLayout(copy, width, height);
|
|
27
|
+
return copy;
|
|
28
|
+
}, [initialNodes, width, height, layout]);
|
|
29
|
+
|
|
30
|
+
// Apply layout-specific positioning when layout changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!nodes || nodes.length === 0) return;
|
|
33
|
+
if (layout === 'circular') applyCircularLayout(nodes, width, height);
|
|
34
|
+
else if (layout === 'hierarchical')
|
|
35
|
+
applyHierarchicalLayout(nodes, width, height);
|
|
36
|
+
|
|
37
|
+
restart();
|
|
38
|
+
}, [layout, nodes, width, height, restart]);
|
|
39
|
+
|
|
40
|
+
return { nodes };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hook for managing simulation controls (stubs for API compatibility).
|
|
45
|
+
*/
|
|
46
|
+
export function useSimulationControls() {
|
|
47
|
+
const restart = useCallback(() => {}, []);
|
|
48
|
+
const stop = useCallback(() => {}, []);
|
|
49
|
+
const setForcesEnabled = useCallback((enabled?: boolean) => {
|
|
50
|
+
void enabled;
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return { restart, stop, setForcesEnabled };
|
|
54
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import * as d3 from 'd3';
|
|
3
|
+
import { GraphNode, LayoutType, ForceDirectedGraphHandle } from './types';
|
|
4
|
+
import { pinNode, unpinAllNodes } from './useGraphInteractions';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_NODE_SIZE,
|
|
7
|
+
FIT_VIEW_PADDING,
|
|
8
|
+
TRANSITION_DURATION_MS,
|
|
9
|
+
} from '../constants';
|
|
10
|
+
|
|
11
|
+
interface UseImperativeHandleProps {
|
|
12
|
+
nodes: GraphNode[];
|
|
13
|
+
pinnedNodes: Set<string>;
|
|
14
|
+
setPinnedNodes: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
15
|
+
restart: () => void;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
layout: LayoutType;
|
|
19
|
+
handleLayoutChange: (layout: LayoutType) => void;
|
|
20
|
+
setForcesEnabled: (enabled: boolean) => void;
|
|
21
|
+
svgRef: React.RefObject<SVGSVGElement | null>;
|
|
22
|
+
gRef: React.RefObject<SVGGElement | null>;
|
|
23
|
+
setTransform: (transform: { k: number; x: number; y: number }) => void;
|
|
24
|
+
internalDragEnabledRef: React.MutableRefObject<boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates the imperative handle methods for the ForceDirectedGraph component.
|
|
29
|
+
*/
|
|
30
|
+
export function useImperativeHandleMethods({
|
|
31
|
+
nodes,
|
|
32
|
+
pinnedNodes,
|
|
33
|
+
setPinnedNodes,
|
|
34
|
+
restart,
|
|
35
|
+
width,
|
|
36
|
+
height,
|
|
37
|
+
layout,
|
|
38
|
+
handleLayoutChange,
|
|
39
|
+
svgRef,
|
|
40
|
+
gRef,
|
|
41
|
+
setTransform,
|
|
42
|
+
internalDragEnabledRef,
|
|
43
|
+
}: UseImperativeHandleProps): ForceDirectedGraphHandle {
|
|
44
|
+
const pinAll = useCallback(() => {
|
|
45
|
+
const newPinned = new Set<string>();
|
|
46
|
+
nodes.forEach((node) => {
|
|
47
|
+
pinNode(node);
|
|
48
|
+
newPinned.add(node.id);
|
|
49
|
+
});
|
|
50
|
+
setPinnedNodes(newPinned);
|
|
51
|
+
restart();
|
|
52
|
+
}, [nodes, setPinnedNodes, restart]);
|
|
53
|
+
|
|
54
|
+
const unpinAll = useCallback(() => {
|
|
55
|
+
unpinAllNodes(nodes);
|
|
56
|
+
setPinnedNodes(new Set());
|
|
57
|
+
restart();
|
|
58
|
+
}, [nodes, setPinnedNodes, restart]);
|
|
59
|
+
|
|
60
|
+
const resetLayout = useCallback(() => {
|
|
61
|
+
unpinAllNodes(nodes);
|
|
62
|
+
setPinnedNodes(new Set());
|
|
63
|
+
restart();
|
|
64
|
+
}, [nodes, setPinnedNodes, restart]);
|
|
65
|
+
|
|
66
|
+
const fitView = useCallback(() => {
|
|
67
|
+
if (!svgRef.current || !nodes.length) return;
|
|
68
|
+
let minX = Infinity,
|
|
69
|
+
maxX = -Infinity,
|
|
70
|
+
minY = Infinity,
|
|
71
|
+
maxY = -Infinity;
|
|
72
|
+
nodes.forEach((node) => {
|
|
73
|
+
if (node.x !== undefined && node.y !== undefined) {
|
|
74
|
+
const size = node.size || DEFAULT_NODE_SIZE;
|
|
75
|
+
minX = Math.min(minX, node.x - size);
|
|
76
|
+
maxX = Math.max(maxX, node.x + size);
|
|
77
|
+
minY = Math.min(minY, node.y - size);
|
|
78
|
+
maxY = Math.max(maxY, node.y + size);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (!isFinite(minX)) return;
|
|
82
|
+
const scale = Math.min(
|
|
83
|
+
(width - FIT_VIEW_PADDING * 2) / (maxX - minX),
|
|
84
|
+
(height - FIT_VIEW_PADDING * 2) / (maxY - minY),
|
|
85
|
+
10
|
|
86
|
+
);
|
|
87
|
+
const x = width / 2 - ((minX + maxX) / 2) * scale;
|
|
88
|
+
const y = height / 2 - ((minY + maxY) / 2) * scale;
|
|
89
|
+
if (gRef.current && svgRef.current) {
|
|
90
|
+
const svg = d3.select(svgRef.current);
|
|
91
|
+
const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
|
|
92
|
+
svg
|
|
93
|
+
.transition()
|
|
94
|
+
.duration(TRANSITION_DURATION_MS)
|
|
95
|
+
.call((d3 as any).zoom().transform as any, newTransform);
|
|
96
|
+
setTransform(newTransform);
|
|
97
|
+
}
|
|
98
|
+
}, [nodes, width, height, svgRef, gRef, setTransform]);
|
|
99
|
+
|
|
100
|
+
const getPinnedNodes = useCallback(
|
|
101
|
+
() => Array.from(pinnedNodes),
|
|
102
|
+
[pinnedNodes]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const setDragMode = useCallback(
|
|
106
|
+
(enabled: boolean) => {
|
|
107
|
+
internalDragEnabledRef.current = enabled;
|
|
108
|
+
},
|
|
109
|
+
[internalDragEnabledRef]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const setLayoutMethod = useCallback(
|
|
113
|
+
(newLayout: LayoutType) => {
|
|
114
|
+
handleLayoutChange(newLayout);
|
|
115
|
+
},
|
|
116
|
+
[handleLayoutChange]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const getLayout = useCallback(() => layout, [layout]);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
pinAll,
|
|
123
|
+
unpinAll,
|
|
124
|
+
resetLayout,
|
|
125
|
+
fitView,
|
|
126
|
+
getPinnedNodes,
|
|
127
|
+
setDragMode,
|
|
128
|
+
setLayout: setLayoutMethod,
|
|
129
|
+
getLayout,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import { Badge } from '../../badge';
|
|
6
|
+
|
|
7
|
+
describe('Badge', () => {
|
|
8
|
+
describe('Rendering', () => {
|
|
9
|
+
it('renders with children', () => {
|
|
10
|
+
render(<Badge>Test Badge</Badge>);
|
|
11
|
+
expect(screen.getByText('Test Badge')).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders with default variant', () => {
|
|
15
|
+
render(<Badge data-testid="badge">Default</Badge>);
|
|
16
|
+
const badge = screen.getByTestId('badge');
|
|
17
|
+
expect(badge).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Variants', () => {
|
|
22
|
+
it('renders default variant', () => {
|
|
23
|
+
render(
|
|
24
|
+
<Badge variant="default" data-testid="badge">
|
|
25
|
+
Default
|
|
26
|
+
</Badge>
|
|
27
|
+
);
|
|
28
|
+
const badge = screen.getByTestId('badge');
|
|
29
|
+
expect(badge).toHaveClass('bg-primary');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders secondary variant', () => {
|
|
33
|
+
render(
|
|
34
|
+
<Badge variant="secondary" data-testid="badge">
|
|
35
|
+
Secondary
|
|
36
|
+
</Badge>
|
|
37
|
+
);
|
|
38
|
+
const badge = screen.getByTestId('badge');
|
|
39
|
+
expect(badge).toHaveClass('bg-secondary');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders destructive variant', () => {
|
|
43
|
+
render(
|
|
44
|
+
<Badge variant="destructive" data-testid="badge">
|
|
45
|
+
Destructive
|
|
46
|
+
</Badge>
|
|
47
|
+
);
|
|
48
|
+
const badge = screen.getByTestId('badge');
|
|
49
|
+
expect(badge).toHaveClass('bg-destructive');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders outline variant', () => {
|
|
53
|
+
render(
|
|
54
|
+
<Badge variant="outline" data-testid="badge">
|
|
55
|
+
Outline
|
|
56
|
+
</Badge>
|
|
57
|
+
);
|
|
58
|
+
const badge = screen.getByTestId('badge');
|
|
59
|
+
expect(badge).toHaveClass('text-foreground');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('Children', () => {
|
|
64
|
+
it('renders text children', () => {
|
|
65
|
+
render(<Badge>Badge Text</Badge>);
|
|
66
|
+
expect(screen.getByText('Badge Text')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders complex children', () => {
|
|
70
|
+
render(
|
|
71
|
+
<Badge>
|
|
72
|
+
<span>Icon</span>
|
|
73
|
+
<span>Label</span>
|
|
74
|
+
</Badge>
|
|
75
|
+
);
|
|
76
|
+
expect(screen.getByText('Icon')).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByText('Label')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('Custom className', () => {
|
|
82
|
+
it('merges custom className with variant classes', () => {
|
|
83
|
+
render(
|
|
84
|
+
<Badge className="custom-class" data-testid="badge">
|
|
85
|
+
Badge
|
|
86
|
+
</Badge>
|
|
87
|
+
);
|
|
88
|
+
const badge = screen.getByTestId('badge');
|
|
89
|
+
expect(badge).toHaveClass('custom-class');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
7
|
+
import * as matchers from '@testing-library/jest-dom/matchers';
|
|
8
|
+
import { expect as vitestExpect } from 'vitest';
|
|
9
|
+
import { Button } from '../../button';
|
|
10
|
+
|
|
11
|
+
// Extend vitest's expect with jest-dom matchers
|
|
12
|
+
vitestExpect.extend(matchers);
|
|
13
|
+
|
|
14
|
+
describe('Button', () => {
|
|
15
|
+
describe('rendering', () => {
|
|
16
|
+
it('renders a button element', () => {
|
|
17
|
+
render(<Button>Click me</Button>);
|
|
18
|
+
const button = screen.getByRole('button', { name: /click me/i });
|
|
19
|
+
expect(button).toBeInTheDocument();
|
|
20
|
+
expect(button.tagName).toBe('BUTTON');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders children correctly', () => {
|
|
24
|
+
render(<Button>Test Content</Button>);
|
|
25
|
+
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('applies custom className', () => {
|
|
29
|
+
render(<Button className="custom-class">Click me</Button>);
|
|
30
|
+
const button = screen.getByRole('button');
|
|
31
|
+
expect(button).toHaveClass('custom-class');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('forwards ref correctly', () => {
|
|
35
|
+
const ref = React.createRef<HTMLButtonElement>();
|
|
36
|
+
render(<Button ref={ref}>Click me</Button>);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('passes through HTML attributes', () => {
|
|
41
|
+
render(
|
|
42
|
+
<Button data-testid="test-button" type="submit">
|
|
43
|
+
Click me
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
const button = screen.getByTestId('test-button');
|
|
47
|
+
expect(button).toHaveAttribute('type', 'submit');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('variants', () => {
|
|
52
|
+
it('renders default variant by default', () => {
|
|
53
|
+
render(<Button>Default</Button>);
|
|
54
|
+
const button = screen.getByRole('button');
|
|
55
|
+
expect(button).toHaveClass('bg-primary', 'text-primary-foreground');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders secondary variant', () => {
|
|
59
|
+
render(<Button variant="secondary">Secondary</Button>);
|
|
60
|
+
const button = screen.getByRole('button');
|
|
61
|
+
expect(button).toHaveClass('bg-secondary', 'text-secondary-foreground');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders outline variant', () => {
|
|
65
|
+
render(<Button variant="outline">Outline</Button>);
|
|
66
|
+
const button = screen.getByRole('button');
|
|
67
|
+
expect(button).toHaveClass('border', 'border-input', 'bg-background');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders ghost variant', () => {
|
|
71
|
+
render(<Button variant="ghost">Ghost</Button>);
|
|
72
|
+
const button = screen.getByRole('button');
|
|
73
|
+
expect(button).toHaveClass('hover:bg-accent');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders destructive variant', () => {
|
|
77
|
+
render(<Button variant="destructive">Destructive</Button>);
|
|
78
|
+
const button = screen.getByRole('button');
|
|
79
|
+
expect(button).toHaveClass(
|
|
80
|
+
'bg-destructive',
|
|
81
|
+
'text-destructive-foreground'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders link variant', () => {
|
|
86
|
+
render(<Button variant="link">Link</Button>);
|
|
87
|
+
const button = screen.getByRole('button');
|
|
88
|
+
expect(button).toHaveClass('text-primary', 'underline-offset-4');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('sizes', () => {
|
|
93
|
+
it('renders default size by default', () => {
|
|
94
|
+
render(<Button>Default Size</Button>);
|
|
95
|
+
const button = screen.getByRole('button');
|
|
96
|
+
expect(button).toHaveClass('h-10', 'px-4', 'py-2');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('renders sm size', () => {
|
|
100
|
+
render(<Button size="sm">Small</Button>);
|
|
101
|
+
const button = screen.getByRole('button');
|
|
102
|
+
expect(button).toHaveClass('h-9', 'px-3');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('renders lg size', () => {
|
|
106
|
+
render(<Button size="lg">Large</Button>);
|
|
107
|
+
const button = screen.getByRole('button');
|
|
108
|
+
expect(button).toHaveClass('h-11', 'px-8');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders icon size', () => {
|
|
112
|
+
render(<Button size="icon">Icon</Button>);
|
|
113
|
+
const button = screen.getByRole('button');
|
|
114
|
+
expect(button).toHaveClass('h-10', 'w-10');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('disabled state', () => {
|
|
119
|
+
it('is disabled when disabled prop is true', () => {
|
|
120
|
+
render(<Button disabled>Disabled</Button>);
|
|
121
|
+
const button = screen.getByRole('button');
|
|
122
|
+
expect(button).toBeDisabled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('applies disabled styles', () => {
|
|
126
|
+
render(<Button disabled>Disabled</Button>);
|
|
127
|
+
const button = screen.getByRole('button');
|
|
128
|
+
expect(button).toHaveClass(
|
|
129
|
+
'disabled:pointer-events-none',
|
|
130
|
+
'disabled:opacity-50'
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('is not disabled by default', () => {
|
|
135
|
+
render(<Button>Not Disabled</Button>);
|
|
136
|
+
const button = screen.getByRole('button');
|
|
137
|
+
expect(button).not.toBeDisabled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('onClick handler', () => {
|
|
142
|
+
it('calls onClick when clicked', () => {
|
|
143
|
+
const handleClick = vi.fn();
|
|
144
|
+
render(<Button onClick={handleClick}>Click me</Button>);
|
|
145
|
+
|
|
146
|
+
const button = screen.getByRole('button');
|
|
147
|
+
fireEvent.click(button);
|
|
148
|
+
|
|
149
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not call onClick when disabled', () => {
|
|
153
|
+
const handleClick = vi.fn();
|
|
154
|
+
render(
|
|
155
|
+
<Button onClick={handleClick} disabled>
|
|
156
|
+
Click me
|
|
157
|
+
</Button>
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const button = screen.getByRole('button');
|
|
161
|
+
fireEvent.click(button);
|
|
162
|
+
|
|
163
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('passes event object to onClick handler', () => {
|
|
167
|
+
const handleClick = vi.fn();
|
|
168
|
+
render(<Button onClick={handleClick}>Click me</Button>);
|
|
169
|
+
|
|
170
|
+
const button = screen.getByRole('button');
|
|
171
|
+
fireEvent.click(button);
|
|
172
|
+
|
|
173
|
+
expect(handleClick).toHaveBeenCalledWith(expect.any(Object));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('combined props', () => {
|
|
178
|
+
it('applies multiple props correctly', () => {
|
|
179
|
+
const handleClick = vi.fn();
|
|
180
|
+
render(
|
|
181
|
+
<Button
|
|
182
|
+
variant="outline"
|
|
183
|
+
size="lg"
|
|
184
|
+
className="custom-class"
|
|
185
|
+
onClick={handleClick}
|
|
186
|
+
disabled
|
|
187
|
+
>
|
|
188
|
+
Combined Props
|
|
189
|
+
</Button>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const button = screen.getByRole('button');
|
|
193
|
+
expect(button).toHaveClass(
|
|
194
|
+
'border',
|
|
195
|
+
'border-input',
|
|
196
|
+
'h-11',
|
|
197
|
+
'px-8',
|
|
198
|
+
'custom-class'
|
|
199
|
+
);
|
|
200
|
+
expect(button).toBeDisabled();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|