@elizaos/client 1.5.5-alpha.10

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.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -0
  3. package/dist/assets/empty-module-CLMscLYw.js +1 -0
  4. package/dist/assets/main-BBZ_3lkn.css +5999 -0
  5. package/dist/assets/main-C5zNUkXH.js +7 -0
  6. package/dist/assets/main-Dz64ENQg.js +614 -0
  7. package/dist/assets/react-vendor-DM5m98rr.js +545 -0
  8. package/dist/assets/ui-vendor-BQCqNqg0.js +1 -0
  9. package/dist/elizaos-avatar.png +0 -0
  10. package/dist/elizaos-icon.png +0 -0
  11. package/dist/elizaos-logo-light.png +0 -0
  12. package/dist/elizaos.webp +0 -0
  13. package/dist/favicon.ico +0 -0
  14. package/dist/images/agents/agent1.png +0 -0
  15. package/dist/images/agents/agent2.png +0 -0
  16. package/dist/images/agents/agent3.png +0 -0
  17. package/dist/images/agents/agent4.png +0 -0
  18. package/dist/images/agents/agent5.png +0 -0
  19. package/dist/index.html +14 -0
  20. package/index.html +24 -0
  21. package/package.json +159 -0
  22. package/postcss.config.js +3 -0
  23. package/public/elizaos-avatar.png +0 -0
  24. package/public/elizaos-icon.png +0 -0
  25. package/public/elizaos-logo-light.png +0 -0
  26. package/public/elizaos.webp +0 -0
  27. package/public/favicon.ico +0 -0
  28. package/public/images/agents/agent1.png +0 -0
  29. package/public/images/agents/agent2.png +0 -0
  30. package/public/images/agents/agent3.png +0 -0
  31. package/public/images/agents/agent4.png +0 -0
  32. package/public/images/agents/agent5.png +0 -0
  33. package/src/App.tsx +222 -0
  34. package/src/components/AgentDetailsPanel.tsx +147 -0
  35. package/src/components/ChatInputArea.tsx +196 -0
  36. package/src/components/ChatMessageListComponent.tsx +139 -0
  37. package/src/components/actionTool.tsx +186 -0
  38. package/src/components/add-agent-card.tsx +77 -0
  39. package/src/components/agent-action-viewer.tsx +816 -0
  40. package/src/components/agent-avatar-stack.tsx +121 -0
  41. package/src/components/agent-card.cy.tsx +259 -0
  42. package/src/components/agent-card.tsx +177 -0
  43. package/src/components/agent-creator.tsx +142 -0
  44. package/src/components/agent-log-viewer.tsx +645 -0
  45. package/src/components/agent-memory-edit-overlay.tsx +461 -0
  46. package/src/components/agent-memory-viewer.tsx +504 -0
  47. package/src/components/agent-settings.tsx +270 -0
  48. package/src/components/agent-sidebar.tsx +178 -0
  49. package/src/components/api-key-dialog.tsx +113 -0
  50. package/src/components/app-sidebar.tsx +685 -0
  51. package/src/components/array-input.tsx +116 -0
  52. package/src/components/audio-recorder.tsx +292 -0
  53. package/src/components/avatar-panel.tsx +141 -0
  54. package/src/components/character-form.tsx +1138 -0
  55. package/src/components/chat.tsx +1813 -0
  56. package/src/components/combobox.tsx +187 -0
  57. package/src/components/confirmation-dialog.tsx +59 -0
  58. package/src/components/connection-error-banner.tsx +101 -0
  59. package/src/components/connection-status.cy.tsx +73 -0
  60. package/src/components/connection-status.tsx +155 -0
  61. package/src/components/copy-button.tsx +35 -0
  62. package/src/components/delete-button.tsx +24 -0
  63. package/src/components/env-settings.tsx +261 -0
  64. package/src/components/group-card.tsx +160 -0
  65. package/src/components/group-panel.tsx +543 -0
  66. package/src/components/input-copy.tsx +21 -0
  67. package/src/components/logs-page.tsx +41 -0
  68. package/src/components/media-content.tsx +385 -0
  69. package/src/components/memory-graph.tsx +170 -0
  70. package/src/components/missing-secrets-dialog.tsx +72 -0
  71. package/src/components/onboarding-tour.tsx +247 -0
  72. package/src/components/page-title.tsx +8 -0
  73. package/src/components/plugins-panel.tsx +383 -0
  74. package/src/components/profile-card.tsx +66 -0
  75. package/src/components/profile-overlay.tsx +283 -0
  76. package/src/components/retry-button.tsx +28 -0
  77. package/src/components/secret-panel.tsx +1505 -0
  78. package/src/components/server-management.tsx +264 -0
  79. package/src/components/split-button.tsx +148 -0
  80. package/src/components/stop-agent-button.tsx +99 -0
  81. package/src/components/ui/alert-dialog.cy.tsx +333 -0
  82. package/src/components/ui/alert-dialog.tsx +115 -0
  83. package/src/components/ui/alert.tsx +49 -0
  84. package/src/components/ui/avatar.cy.tsx +180 -0
  85. package/src/components/ui/avatar.tsx +57 -0
  86. package/src/components/ui/badge.cy.tsx +146 -0
  87. package/src/components/ui/badge.tsx +43 -0
  88. package/src/components/ui/button.cy.tsx +177 -0
  89. package/src/components/ui/button.tsx +56 -0
  90. package/src/components/ui/card.cy.tsx +160 -0
  91. package/src/components/ui/card.tsx +73 -0
  92. package/src/components/ui/chat/animated-markdown.tsx +59 -0
  93. package/src/components/ui/chat/chat-bubble.tsx +178 -0
  94. package/src/components/ui/chat/chat-container.tsx +51 -0
  95. package/src/components/ui/chat/chat-input.cy.tsx +169 -0
  96. package/src/components/ui/chat/chat-input.tsx +47 -0
  97. package/src/components/ui/chat/chat-message-list.tsx +61 -0
  98. package/src/components/ui/chat/chat-tts-button.tsx +199 -0
  99. package/src/components/ui/chat/code-block.tsx +79 -0
  100. package/src/components/ui/chat/expandable-chat.tsx +131 -0
  101. package/src/components/ui/chat/hooks/useAutoScroll.ts +86 -0
  102. package/src/components/ui/chat/markdown.tsx +209 -0
  103. package/src/components/ui/chat/message-loading.tsx +48 -0
  104. package/src/components/ui/checkbox.cy.tsx +170 -0
  105. package/src/components/ui/checkbox.tsx +30 -0
  106. package/src/components/ui/collapsible.cy.tsx +283 -0
  107. package/src/components/ui/collapsible.tsx +9 -0
  108. package/src/components/ui/command.cy.tsx +313 -0
  109. package/src/components/ui/command.tsx +143 -0
  110. package/src/components/ui/dialog.cy.tsx +279 -0
  111. package/src/components/ui/dialog.tsx +104 -0
  112. package/src/components/ui/dropdown-menu.cy.tsx +273 -0
  113. package/src/components/ui/dropdown-menu.tsx +281 -0
  114. package/src/components/ui/input.cy.tsx +82 -0
  115. package/src/components/ui/input.tsx +27 -0
  116. package/src/components/ui/label.cy.tsx +157 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/resizable.tsx +42 -0
  119. package/src/components/ui/scroll-area.cy.tsx +242 -0
  120. package/src/components/ui/scroll-area.tsx +46 -0
  121. package/src/components/ui/select.cy.tsx +277 -0
  122. package/src/components/ui/select.tsx +155 -0
  123. package/src/components/ui/separator.cy.tsx +145 -0
  124. package/src/components/ui/separator.tsx +29 -0
  125. package/src/components/ui/sheet.cy.tsx +324 -0
  126. package/src/components/ui/sheet.tsx +119 -0
  127. package/src/components/ui/sidebar.tsx +734 -0
  128. package/src/components/ui/skeleton.cy.tsx +149 -0
  129. package/src/components/ui/skeleton.tsx +17 -0
  130. package/src/components/ui/split-button.cy.tsx +274 -0
  131. package/src/components/ui/split-button.tsx +112 -0
  132. package/src/components/ui/switch.tsx +28 -0
  133. package/src/components/ui/tabs.cy.tsx +271 -0
  134. package/src/components/ui/tabs.tsx +53 -0
  135. package/src/components/ui/textarea.cy.tsx +136 -0
  136. package/src/components/ui/textarea.tsx +26 -0
  137. package/src/components/ui/toast.cy.tsx +209 -0
  138. package/src/components/ui/toast.tsx +126 -0
  139. package/src/components/ui/toaster.tsx +29 -0
  140. package/src/components/ui/tooltip.cy.tsx +244 -0
  141. package/src/components/ui/tooltip.tsx +30 -0
  142. package/src/config/agent-templates.ts +349 -0
  143. package/src/config/voice-models.ts +181 -0
  144. package/src/constants.ts +23 -0
  145. package/src/context/AuthContext.tsx +44 -0
  146. package/src/context/ConnectionContext.tsx +194 -0
  147. package/src/entry.tsx +9 -0
  148. package/src/hooks/__tests__/use-agent-tab-state.test.ts +137 -0
  149. package/src/hooks/__tests__/use-agent-update.test.tsx +250 -0
  150. package/src/hooks/__tests__/use-character-convert.test.ts +102 -0
  151. package/src/hooks/__tests__/use-panel-width-state.test.ts +243 -0
  152. package/src/hooks/__tests__/use-sidebar-state.test.ts +117 -0
  153. package/src/hooks/use-agent-management.ts +130 -0
  154. package/src/hooks/use-agent-tab-state.ts +74 -0
  155. package/src/hooks/use-agent-update.ts +469 -0
  156. package/src/hooks/use-character-convert.ts +138 -0
  157. package/src/hooks/use-confirmation.ts +55 -0
  158. package/src/hooks/use-delete-agent.ts +123 -0
  159. package/src/hooks/use-dm-channels.ts +198 -0
  160. package/src/hooks/use-elevenlabs-voices.ts +83 -0
  161. package/src/hooks/use-file-upload.ts +224 -0
  162. package/src/hooks/use-mobile.tsx +19 -0
  163. package/src/hooks/use-onboarding.tsx +49 -0
  164. package/src/hooks/use-panel-width-state.ts +147 -0
  165. package/src/hooks/use-partial-update.ts +288 -0
  166. package/src/hooks/use-plugin-details.ts +462 -0
  167. package/src/hooks/use-plugins.ts +119 -0
  168. package/src/hooks/use-query-hooks.ts +1263 -0
  169. package/src/hooks/use-server-agents.ts +62 -0
  170. package/src/hooks/use-server-version.tsx +47 -0
  171. package/src/hooks/use-sidebar-state.ts +50 -0
  172. package/src/hooks/use-socket-chat.ts +264 -0
  173. package/src/hooks/use-toast.ts +260 -0
  174. package/src/hooks/use-version.tsx +64 -0
  175. package/src/index.css +146 -0
  176. package/src/lib/api-client-config.ts +53 -0
  177. package/src/lib/api-type-mappers.ts +196 -0
  178. package/src/lib/export-utils.ts +123 -0
  179. package/src/lib/logger.ts +19 -0
  180. package/src/lib/media-utils.ts +170 -0
  181. package/src/lib/pca.test.ts +17 -0
  182. package/src/lib/pca.ts +52 -0
  183. package/src/lib/socketio-manager.ts +664 -0
  184. package/src/lib/utils.ts +168 -0
  185. package/src/main.tsx +16 -0
  186. package/src/mocks/empty-module.ts +12 -0
  187. package/src/mocks/node-module.ts +57 -0
  188. package/src/polyfills.ts +37 -0
  189. package/src/routes/agent-detail.tsx +30 -0
  190. package/src/routes/agent-list.tsx +27 -0
  191. package/src/routes/agent-settings.tsx +48 -0
  192. package/src/routes/character-detail.tsx +52 -0
  193. package/src/routes/character-form.tsx +79 -0
  194. package/src/routes/character-list.tsx +38 -0
  195. package/src/routes/chat.tsx +128 -0
  196. package/src/routes/createAgent.tsx +13 -0
  197. package/src/routes/group-new.tsx +50 -0
  198. package/src/routes/group.tsx +29 -0
  199. package/src/routes/home.tsx +218 -0
  200. package/src/routes/not-found.tsx +71 -0
  201. package/src/test/setup.ts +154 -0
  202. package/src/types/crypto-browserify.d.ts +4 -0
  203. package/src/types/index.ts +13 -0
  204. package/src/types/rooms.ts +8 -0
  205. package/src/types.ts +84 -0
  206. package/src/vite-env.d.ts +40 -0
  207. package/tailwind.config.ts +90 -0
  208. package/tsconfig.json +10 -0
  209. package/vite.config.ts +102 -0
@@ -0,0 +1,149 @@
1
+ /// <reference types="cypress" />
2
+ /// <reference path="../../../cypress/support/types.d.ts" />
3
+
4
+ import React from 'react';
5
+ import { Skeleton } from './skeleton';
6
+
7
+ describe('Skeleton Component', () => {
8
+ it('renders correctly with default props', () => {
9
+ cy.mount(<Skeleton />);
10
+
11
+ cy.get('[data-testid="skeleton"]').should('exist');
12
+ cy.get('[data-testid="skeleton"]').should('have.class', 'animate-pulse');
13
+ cy.get('[data-testid="skeleton"]').should('have.class', 'rounded-md');
14
+ cy.get('[data-testid="skeleton"]').should('have.class', 'bg-primary/10');
15
+ });
16
+
17
+ it('applies custom className', () => {
18
+ cy.mount(<Skeleton className="w-full h-20" />);
19
+
20
+ cy.get('[data-testid="skeleton"]').should('have.class', 'w-full').should('have.class', 'h-20');
21
+ });
22
+
23
+ it('renders multiple skeletons for loading lists', () => {
24
+ cy.mount(
25
+ <div className="space-y-2">
26
+ <Skeleton className="h-4 w-full" />
27
+ <Skeleton className="h-4 w-3/4" />
28
+ <Skeleton className="h-4 w-1/2" />
29
+ </div>
30
+ );
31
+
32
+ cy.get('[data-testid="skeleton"]').should('have.length', 3);
33
+ cy.get('[data-testid="skeleton"]').first().should('have.class', 'w-full');
34
+ cy.get('[data-testid="skeleton"]').eq(1).should('have.class', 'w-3/4');
35
+ cy.get('[data-testid="skeleton"]').last().should('have.class', 'w-1/2');
36
+ });
37
+
38
+ it('renders card skeleton layout', () => {
39
+ cy.mount(
40
+ <div className="rounded-lg border p-4">
41
+ <div className="flex items-center space-x-4">
42
+ <Skeleton className="h-12 w-12 rounded-full" />
43
+ <div className="space-y-2">
44
+ <Skeleton className="h-4 w-[250px]" />
45
+ <Skeleton className="h-4 w-[200px]" />
46
+ </div>
47
+ </div>
48
+ </div>
49
+ );
50
+
51
+ cy.get('[data-testid="skeleton"]').should('have.length', 3);
52
+ cy.get('.rounded-full').should('exist');
53
+ });
54
+
55
+ it('supports different shapes', () => {
56
+ cy.mount(
57
+ <div className="space-y-4">
58
+ <Skeleton className="h-12 w-12 rounded-full" />
59
+ <Skeleton className="h-20 w-full rounded-lg" />
60
+ <Skeleton className="h-4 w-32" />
61
+ </div>
62
+ );
63
+
64
+ cy.get('[data-testid="skeleton"]').first().should('have.class', 'rounded-full');
65
+ cy.get('[data-testid="skeleton"]').eq(1).should('have.class', 'rounded-lg');
66
+ });
67
+
68
+ it('works for text loading', () => {
69
+ cy.mount(
70
+ <div className="space-y-2">
71
+ <h2 className="text-2xl font-bold">
72
+ <Skeleton className="h-8 w-48" />
73
+ </h2>
74
+ <p>
75
+ <Skeleton className="h-4 w-full" />
76
+ <Skeleton className="h-4 w-full" />
77
+ <Skeleton className="h-4 w-3/4" />
78
+ </p>
79
+ </div>
80
+ );
81
+
82
+ cy.get('h2').find('[data-testid="skeleton"]').should('exist');
83
+ cy.get('p').find('[data-testid="skeleton"]').should('have.length', 3);
84
+ });
85
+
86
+ it('maintains proper sizing', () => {
87
+ cy.mount(<Skeleton className="h-32 w-64" />);
88
+
89
+ cy.get('[data-testid="skeleton"]').should('have.class', 'h-32').should('have.class', 'w-64');
90
+ });
91
+
92
+ it('works for image placeholders', () => {
93
+ cy.mount(
94
+ <div className="grid grid-cols-3 gap-4">
95
+ <Skeleton className="aspect-square w-full rounded-lg" />
96
+ <Skeleton className="aspect-square w-full rounded-lg" />
97
+ <Skeleton className="aspect-square w-full rounded-lg" />
98
+ </div>
99
+ );
100
+
101
+ cy.get('[data-testid="skeleton"]').should('have.length', 3);
102
+ cy.get('[data-testid="skeleton"]').each(($el) => {
103
+ cy.wrap($el).should('have.class', 'aspect-square');
104
+ });
105
+ });
106
+
107
+ it('works for form field loading', () => {
108
+ cy.mount(
109
+ <div className="space-y-4">
110
+ <div className="space-y-2">
111
+ <Skeleton className="h-4 w-24" /> {/* Label */}
112
+ <Skeleton className="h-10 w-full rounded-md" /> {/* Input */}
113
+ </div>
114
+ <div className="space-y-2">
115
+ <Skeleton className="h-4 w-32" /> {/* Label */}
116
+ <Skeleton className="h-24 w-full rounded-md" /> {/* Textarea */}
117
+ </div>
118
+ </div>
119
+ );
120
+
121
+ cy.get('[data-testid="skeleton"]').should('have.length', 4);
122
+ });
123
+
124
+ it('supports data attributes', () => {
125
+ cy.mount(
126
+ <Skeleton data-testid="custom-skeleton" data-loading="true" className="h-20 w-full" />
127
+ );
128
+
129
+ cy.get('[data-testid="custom-skeleton"]')
130
+ .should('exist')
131
+ .should('have.attr', 'data-loading', 'true');
132
+ });
133
+
134
+ it('animation is present', () => {
135
+ cy.mount(<Skeleton />);
136
+
137
+ cy.get('[data-testid="skeleton"]').should('have.class', 'animate-pulse');
138
+ });
139
+
140
+ it('works in dark mode', () => {
141
+ cy.mount(
142
+ <div className="dark bg-gray-900 p-4">
143
+ <Skeleton className="h-20 w-full" />
144
+ </div>
145
+ );
146
+
147
+ cy.get('[data-testid="skeleton"]').should('be.visible');
148
+ });
149
+ });
@@ -0,0 +1,17 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ 'data-testid'?: string;
5
+ }
6
+
7
+ function Skeleton({ className, ...props }: SkeletonProps) {
8
+ return (
9
+ <div
10
+ className={cn('animate-pulse rounded-md bg-primary/10', className)}
11
+ data-testid={props['data-testid'] || 'skeleton'}
12
+ {...props}
13
+ />
14
+ );
15
+ }
16
+
17
+ export { Skeleton };
@@ -0,0 +1,274 @@
1
+ /// <reference types="cypress" />
2
+ /// <reference path="../../../cypress/support/types.d.ts" />
3
+
4
+ import React from 'react';
5
+ import { SplitButton } from './split-button';
6
+
7
+ describe('SplitButton Component', () => {
8
+ it('renders basic split button', () => {
9
+ cy.mount(
10
+ <SplitButton
11
+ mainAction={{ label: 'Save', onClick: () => {} }}
12
+ actions={[
13
+ { label: 'Save as Draft', onClick: () => {} },
14
+ { label: 'Save and Exit', onClick: () => {} },
15
+ ]}
16
+ />
17
+ );
18
+
19
+ cy.contains('button', 'Save').should('be.visible');
20
+ cy.get('button[aria-haspopup="menu"]').should('be.visible');
21
+ });
22
+
23
+ it('handles main button click', () => {
24
+ const onClick = cy.stub();
25
+
26
+ cy.mount(
27
+ <SplitButton
28
+ mainAction={{ label: 'Submit', onClick }}
29
+ actions={[{ label: 'Submit and Close', onClick: () => {} }]}
30
+ />
31
+ );
32
+
33
+ cy.contains('button', 'Submit').click();
34
+ cy.wrap(onClick).should('have.been.called');
35
+ });
36
+
37
+ it('opens dropdown menu', () => {
38
+ cy.mount(
39
+ <SplitButton
40
+ mainAction={{ label: 'Export', onClick: () => {} }}
41
+ actions={[
42
+ { label: 'Export as PDF', onClick: () => {} },
43
+ { label: 'Export as CSV', onClick: () => {} },
44
+ { label: 'Export as Excel', onClick: () => {} },
45
+ ]}
46
+ />
47
+ );
48
+
49
+ // Initially dropdown is closed
50
+ cy.contains('Export as PDF').should('not.exist');
51
+
52
+ // Click dropdown trigger
53
+ cy.get('button[aria-haspopup="menu"]').click();
54
+
55
+ // All options should be visible
56
+ cy.contains('Export as PDF').should('be.visible');
57
+ cy.contains('Export as CSV').should('be.visible');
58
+ cy.contains('Export as Excel').should('be.visible');
59
+ });
60
+
61
+ it('handles dropdown action selection', () => {
62
+ const mainAction = cy.stub();
63
+ const draftAction = cy.stub();
64
+ const templateAction = cy.stub();
65
+
66
+ cy.mount(
67
+ <SplitButton
68
+ mainAction={{ label: 'Save', onClick: mainAction }}
69
+ actions={[
70
+ { label: 'Save as Draft', onClick: draftAction },
71
+ { label: 'Save as Template', onClick: templateAction },
72
+ ]}
73
+ />
74
+ );
75
+
76
+ // Open dropdown
77
+ cy.get('button[aria-haspopup="menu"]').click();
78
+
79
+ // Select draft option
80
+ cy.contains('Save as Draft').click();
81
+ cy.wrap(draftAction).should('have.been.called');
82
+ });
83
+
84
+ it('supports different variants', () => {
85
+ cy.mount(
86
+ <div className="space-x-4">
87
+ <SplitButton
88
+ mainAction={{ label: 'Default', onClick: () => {} }}
89
+ actions={[{ label: 'Option', onClick: () => {} }]}
90
+ variant="default"
91
+ />
92
+ <SplitButton
93
+ mainAction={{ label: 'Destructive', onClick: () => {} }}
94
+ actions={[{ label: 'Delete All', onClick: () => {} }]}
95
+ variant="destructive"
96
+ />
97
+ <SplitButton
98
+ mainAction={{ label: 'Outline', onClick: () => {} }}
99
+ actions={[{ label: 'More', onClick: () => {} }]}
100
+ variant="outline"
101
+ />
102
+ </div>
103
+ );
104
+
105
+ cy.contains('button', 'Default').should('be.visible');
106
+ cy.contains('button', 'Destructive').should('have.class', 'bg-red-800');
107
+ cy.contains('button', 'Outline').should('have.class', 'border');
108
+ });
109
+
110
+ it('supports different sizes', () => {
111
+ cy.mount(
112
+ <div className="space-x-4">
113
+ <SplitButton
114
+ mainAction={{ label: 'Small', onClick: () => {} }}
115
+ actions={[{ label: 'Option', onClick: () => {} }]}
116
+ size="sm"
117
+ />
118
+ <SplitButton
119
+ mainAction={{ label: 'Default', onClick: () => {} }}
120
+ actions={[{ label: 'Option', onClick: () => {} }]}
121
+ />
122
+ <SplitButton
123
+ mainAction={{ label: 'Large', onClick: () => {} }}
124
+ actions={[{ label: 'Option', onClick: () => {} }]}
125
+ size="lg"
126
+ />
127
+ </div>
128
+ );
129
+
130
+ cy.contains('button', 'Small').should('have.class', 'h-8');
131
+ cy.contains('button', 'Large').should('have.class', 'h-10');
132
+ });
133
+
134
+ it('can be disabled', () => {
135
+ const onClick = cy.stub();
136
+
137
+ cy.mount(
138
+ <SplitButton
139
+ mainAction={{ label: 'Disabled', onClick }}
140
+ actions={[{ label: 'Option 1', onClick: () => {} }]}
141
+ disabled
142
+ />
143
+ );
144
+
145
+ // Both buttons should be disabled
146
+ cy.contains('button', 'Disabled').should('be.disabled');
147
+ cy.get('button[aria-haspopup="menu"]').should('be.disabled');
148
+
149
+ // Try clicking
150
+ cy.contains('button', 'Disabled').click({ force: true });
151
+ cy.wrap(onClick).should('not.have.been.called');
152
+ });
153
+
154
+ it('supports individually disabled actions', () => {
155
+ const enabledAction = cy.stub();
156
+ const disabledAction = cy.stub();
157
+
158
+ cy.mount(
159
+ <SplitButton
160
+ mainAction={{ label: 'Print', onClick: () => {} }}
161
+ actions={[
162
+ { label: 'Print Preview', onClick: enabledAction },
163
+ { label: 'Print to PDF', onClick: disabledAction, disabled: true },
164
+ { label: 'Print All', onClick: enabledAction },
165
+ ]}
166
+ />
167
+ );
168
+
169
+ cy.get('button[aria-haspopup="menu"]').click();
170
+
171
+ // Check disabled item exists
172
+ cy.contains('Print to PDF').should('exist');
173
+ // Disabled dropdown items behavior might vary, just verify enabled action works
174
+
175
+ // Click enabled item
176
+ cy.contains('Print All').click();
177
+ cy.wrap(enabledAction).should('have.been.called');
178
+ });
179
+
180
+ it('closes dropdown after selection', () => {
181
+ cy.mount(
182
+ <SplitButton
183
+ mainAction={{ label: 'Share', onClick: () => {} }}
184
+ actions={[
185
+ { label: 'Share via Email', onClick: () => {} },
186
+ { label: 'Share via Link', onClick: () => {} },
187
+ ]}
188
+ />
189
+ );
190
+
191
+ // Open dropdown
192
+ cy.get('button[aria-haspopup="menu"]').click();
193
+ cy.contains('Share via Email').should('be.visible');
194
+
195
+ // Select item
196
+ cy.contains('Share via Email').click();
197
+
198
+ // Dropdown should close
199
+ cy.contains('Share via Email').should('not.exist');
200
+ });
201
+
202
+ it('supports custom className', () => {
203
+ cy.mount(
204
+ <SplitButton
205
+ mainAction={{ label: 'Custom', onClick: () => {} }}
206
+ actions={[{ label: 'Option', onClick: () => {} }]}
207
+ className="custom-split-button"
208
+ />
209
+ );
210
+
211
+ cy.get('.custom-split-button').should('exist');
212
+ });
213
+
214
+ it('maintains visual consistency', () => {
215
+ cy.mount(
216
+ <SplitButton
217
+ mainAction={{ label: 'Consistent', onClick: () => {} }}
218
+ actions={[
219
+ { label: 'Option 1', onClick: () => {} },
220
+ { label: 'Option 2', onClick: () => {} },
221
+ ]}
222
+ variant="secondary"
223
+ />
224
+ );
225
+
226
+ // Both parts should have consistent styling
227
+ cy.contains('button', 'Consistent').should('be.visible');
228
+ cy.get('button[aria-haspopup="menu"]').should('be.visible');
229
+ });
230
+
231
+ it('supports icons in actions', () => {
232
+ cy.mount(
233
+ <SplitButton
234
+ mainAction={{
235
+ label: 'Save',
236
+ onClick: () => {},
237
+ icon: <span>💾</span>,
238
+ }}
239
+ actions={[
240
+ {
241
+ label: 'Draft',
242
+ onClick: () => {},
243
+ icon: <span>📝</span>,
244
+ },
245
+ {
246
+ label: 'Template',
247
+ onClick: () => {},
248
+ icon: <span>📋</span>,
249
+ },
250
+ ]}
251
+ />
252
+ );
253
+
254
+ cy.contains('💾').should('be.visible');
255
+ cy.get('button[aria-haspopup="menu"]').click();
256
+ cy.contains('📝').should('be.visible');
257
+ cy.contains('📋').should('be.visible');
258
+ });
259
+
260
+ it('supports destructive variant in dropdown', () => {
261
+ cy.mount(
262
+ <SplitButton
263
+ mainAction={{ label: 'Actions', onClick: () => {} }}
264
+ actions={[
265
+ { label: 'Edit', onClick: () => {} },
266
+ { label: 'Delete', onClick: () => {}, variant: 'destructive' },
267
+ ]}
268
+ />
269
+ );
270
+
271
+ cy.get('button[aria-haspopup="menu"]').click();
272
+ cy.contains('Delete').should('have.class', 'text-destructive');
273
+ });
274
+ });
@@ -0,0 +1,112 @@
1
+ import * as React from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuSeparator,
8
+ DropdownMenuTrigger,
9
+ } from '@/components/ui/dropdown-menu';
10
+ import { ChevronDown } from 'lucide-react';
11
+ import { cn } from '@/lib/utils';
12
+
13
+ export interface SplitButtonAction {
14
+ label: React.ReactNode;
15
+ onClick: () => void;
16
+ icon?: React.ReactNode;
17
+ disabled?: boolean;
18
+ variant?: 'default' | 'destructive';
19
+ }
20
+
21
+ export interface SplitButtonProps {
22
+ mainAction: SplitButtonAction;
23
+ actions: SplitButtonAction[];
24
+ variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive';
25
+ size?: 'default' | 'sm' | 'lg' | 'icon';
26
+ disabled?: boolean;
27
+ className?: string;
28
+ mainButtonClassName?: string;
29
+ dropdownButtonClassName?: string;
30
+ }
31
+
32
+ export const SplitButton = React.forwardRef<HTMLDivElement, SplitButtonProps>(
33
+ (
34
+ {
35
+ mainAction,
36
+ actions,
37
+ variant = 'default',
38
+ size = 'default',
39
+ disabled,
40
+ className,
41
+ mainButtonClassName,
42
+ dropdownButtonClassName,
43
+ },
44
+ ref
45
+ ) => {
46
+ const containerRef = React.useRef<HTMLDivElement>(null);
47
+ const [menuWidth, setMenuWidth] = React.useState<number>();
48
+
49
+ React.useImperativeHandle(ref, () => containerRef.current!, []);
50
+
51
+ React.useLayoutEffect(() => {
52
+ if (containerRef.current) {
53
+ setMenuWidth(containerRef.current.offsetWidth);
54
+ }
55
+ }, [actions.length, mainAction.label]);
56
+
57
+ return (
58
+ <div ref={containerRef} className={cn('flex w-full', className)}>
59
+ <Button
60
+ type="button"
61
+ variant={variant}
62
+ size={size}
63
+ onClick={mainAction.onClick}
64
+ disabled={disabled || mainAction.disabled}
65
+ className={cn(
66
+ 'rounded-r-none flex-1',
67
+ variant === 'destructive' ? 'border-r border-red-700' : 'border-r-0',
68
+ mainButtonClassName
69
+ )}
70
+ >
71
+ {mainAction.icon && <span className="mr-2">{mainAction.icon}</span>}
72
+ {mainAction.label}
73
+ </Button>
74
+
75
+ <DropdownMenu>
76
+ <DropdownMenuTrigger asChild>
77
+ <Button
78
+ type="button"
79
+ variant={variant}
80
+ size={size}
81
+ disabled={disabled}
82
+ className={cn('rounded-l-none px-2 flex-shrink-0', dropdownButtonClassName)}
83
+ >
84
+ <ChevronDown className="h-4 w-4" />
85
+ <span className="sr-only">More options</span>
86
+ </Button>
87
+ </DropdownMenuTrigger>
88
+ <DropdownMenuContent align="end" style={{ width: menuWidth }}>
89
+ {actions.map((action, index) => (
90
+ <React.Fragment key={index}>
91
+ <DropdownMenuItem
92
+ onClick={action.onClick}
93
+ disabled={action.disabled}
94
+ className={cn(
95
+ action.variant === 'destructive' &&
96
+ 'text-destructive focus:text-destructive hover:bg-red-50 dark:hover:bg-red-950/50'
97
+ )}
98
+ >
99
+ {action.icon}
100
+ {action.label}
101
+ </DropdownMenuItem>
102
+ {index < actions.length - 1 && <DropdownMenuSeparator />}
103
+ </React.Fragment>
104
+ ))}
105
+ </DropdownMenuContent>
106
+ </DropdownMenu>
107
+ </div>
108
+ );
109
+ }
110
+ );
111
+
112
+ SplitButton.displayName = 'SplitButton';
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import * as SwitchPrimitive from '@radix-ui/react-switch';
5
+
6
+ import { cn } from '@/lib/utils';
7
+
8
+ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
9
+ return (
10
+ <SwitchPrimitive.Root
11
+ data-slot="switch"
12
+ className={cn(
13
+ 'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-4 w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer',
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <SwitchPrimitive.Thumb
19
+ data-slot="switch-thumb"
20
+ className={cn(
21
+ 'bg-background dark:data-[state=unchecked]:bg-primary-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block h-2.5 w-2.5 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%+10px)] data-[state=unchecked]:translate-x-0'
22
+ )}
23
+ />
24
+ </SwitchPrimitive.Root>
25
+ );
26
+ }
27
+
28
+ export { Switch };