@trycompai/design-system 1.0.17 → 1.0.18

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": "@trycompai/design-system",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Design system for Comp AI - shadcn-style components with Tailwind CSS",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -97,7 +97,6 @@
97
97
  "tailwindcss": "^4.0.0"
98
98
  },
99
99
  "devDependencies": {
100
- "@repo/typescript-config": "workspace:*",
101
100
  "@tailwindcss/postcss": "^4.1.10",
102
101
  "@types/node": "^22.18.0",
103
102
  "@types/react": "^19.2.7",
@@ -15,8 +15,6 @@ const textVariants = cva('', {
15
15
  primary: 'text-primary',
16
16
  destructive: 'text-destructive',
17
17
  success: 'text-green-600 dark:text-green-400',
18
- warning: 'text-warning',
19
- info: 'text-info',
20
18
  },
21
19
  weight: {
22
20
  normal: 'font-normal',
@@ -23,7 +23,6 @@ export * from './scroll-area';
23
23
  export * from './section';
24
24
  export * from './select';
25
25
  export * from './settings';
26
- export * from './split-button';
27
26
  export * from './table';
28
27
  export * from './tabs';
29
28
  export * from './theme-switcher';
@@ -79,7 +79,7 @@ function AlertDialogTitle({
79
79
  return (
80
80
  <AlertDialogPrimitive.Title
81
81
  data-slot="alert-dialog-title"
82
- className="text-sm font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2"
82
+ className="text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2"
83
83
  {...props}
84
84
  />
85
85
  );
@@ -91,7 +91,7 @@ function AlertDialogDescription({
91
91
  return (
92
92
  <AlertDialogPrimitive.Description
93
93
  data-slot="alert-dialog-description"
94
- className="text-muted-foreground font-normal *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3"
94
+ className="text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3"
95
95
  {...props}
96
96
  />
97
97
  );
@@ -1,5 +1,4 @@
1
1
  export * from './alert-dialog';
2
- export * from './approval-banner';
3
2
  export * from './app-shell';
4
3
  export * from './calendar';
5
4
  export * from './carousel';
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import { Command as CommandPrimitive } from 'cmdk';
4
+ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
3
5
  import { Combobox as ComboboxPrimitive } from '@base-ui/react';
4
6
  import { Checkmark, ChevronDown, Search } from '@carbon/icons-react';
5
7
  import * as React from 'react';
@@ -33,39 +35,70 @@ export interface OrganizationSelectorProps {
33
35
  /** Search input placeholder */
34
36
  searchPlaceholder?: string;
35
37
  /** Text shown when no results match search */
36
- emptyText?: string;
38
+ emptySearchText?: string;
39
+ /** Text shown when organizations list is empty */
40
+ emptyListText?: string;
37
41
  /** Whether the selector is disabled */
38
42
  disabled?: boolean;
43
+ /** Whether organizations are being loaded */
44
+ loading?: boolean;
45
+ /** Text shown while loading */
46
+ loadingText?: string;
47
+ /** Error message to display */
48
+ error?: string;
39
49
  /** Size variant for the trigger */
40
50
  size?: 'sm' | 'default';
51
+ /** Max width for the organization name in trigger (e.g., '150px') */
52
+ maxWidth?: string;
53
+ /** Keyboard shortcut to open (e.g., 'o' for Cmd+O). Set to null to disable. */
54
+ hotkey?: string | null;
55
+ /** Whether to open as a full-screen modal (like Cmd+K) instead of dropdown */
56
+ modal?: boolean;
41
57
  }
42
58
 
43
59
  // ============================================================================
44
60
  // Internal Components
45
61
  // ============================================================================
46
62
 
47
- function OrganizationColorDot({ color }: { color?: string }) {
48
- if (!color) return null;
49
-
50
- // Support both hex colors and Tailwind classes
51
- const isTailwindClass = color.startsWith('bg-');
52
- const style = isTailwindClass ? undefined : { backgroundColor: color };
53
- const className = isTailwindClass
54
- ? `size-2 shrink-0 rounded-full ${color}`
55
- : 'size-2 shrink-0 rounded-full';
63
+ function LoadingSpinner() {
64
+ return (
65
+ <svg
66
+ className="size-4 animate-spin text-muted-foreground"
67
+ xmlns="http://www.w3.org/2000/svg"
68
+ fill="none"
69
+ viewBox="0 0 24 24"
70
+ >
71
+ <circle
72
+ className="opacity-25"
73
+ cx="12"
74
+ cy="12"
75
+ r="10"
76
+ stroke="currentColor"
77
+ strokeWidth="4"
78
+ />
79
+ <path
80
+ className="opacity-75"
81
+ fill="currentColor"
82
+ 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"
83
+ />
84
+ </svg>
85
+ );
86
+ }
56
87
 
57
- return <span className={className} style={style} />;
88
+ function EmptyState({ icon, text }: { icon?: React.ReactNode; text: string }) {
89
+ return (
90
+ <div className="flex flex-col items-center justify-center gap-2 py-6 text-center">
91
+ {icon && <span className="text-muted-foreground">{icon}</span>}
92
+ <span className="text-muted-foreground text-sm">{text}</span>
93
+ </div>
94
+ );
58
95
  }
59
96
 
60
- function OrganizationItemContent({ org }: { org: Organization }) {
97
+ function OrganizationItemContent({ org, maxWidth }: { org: Organization; maxWidth?: string }) {
61
98
  return (
62
99
  <>
63
- {org.icon ? (
64
- <span className="shrink-0">{org.icon}</span>
65
- ) : (
66
- <OrganizationColorDot color={org.color} />
67
- )}
68
- <span className="truncate">{org.name}</span>
100
+ {org.icon && <span className="shrink-0">{org.icon}</span>}
101
+ <span className="truncate" style={maxWidth ? { maxWidth } : undefined}>{org.name}</span>
69
102
  </>
70
103
  );
71
104
  }
@@ -81,16 +114,44 @@ function OrganizationSelector({
81
114
  onValueChange,
82
115
  placeholder = 'Select organization',
83
116
  searchPlaceholder = 'Search by name or ID...',
84
- emptyText = 'No organizations found',
117
+ emptySearchText = 'No organizations found',
118
+ emptyListText = 'No organizations available',
85
119
  disabled = false,
120
+ loading = false,
121
+ loadingText = 'Loading organizations...',
122
+ error,
86
123
  size = 'default',
124
+ maxWidth = '150px',
125
+ hotkey = 'o',
126
+ modal = false,
87
127
  }: OrganizationSelectorProps) {
88
128
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
89
129
  const [searchQuery, setSearchQuery] = React.useState('');
130
+ const [open, setOpen] = React.useState(false);
131
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
90
132
 
91
133
  const selectedValue = value ?? internalValue;
92
134
  const selectedOrg = organizations.find((org) => org.id === selectedValue);
93
135
 
136
+ // Keyboard shortcut to open (Cmd/Ctrl + hotkey)
137
+ React.useEffect(() => {
138
+ if (!hotkey || disabled || loading) return;
139
+
140
+ const handleKeyDown = (event: KeyboardEvent) => {
141
+ if (event.key.toLowerCase() === hotkey.toLowerCase() && (event.metaKey || event.ctrlKey)) {
142
+ event.preventDefault();
143
+ if (modal) {
144
+ setOpen(true);
145
+ } else {
146
+ triggerRef.current?.click();
147
+ }
148
+ }
149
+ };
150
+
151
+ window.addEventListener('keydown', handleKeyDown);
152
+ return () => window.removeEventListener('keydown', handleKeyDown);
153
+ }, [hotkey, disabled, loading, modal]);
154
+
94
155
  // Filter organizations by ID or name (case-insensitive)
95
156
  const filteredOrganizations = React.useMemo(() => {
96
157
  if (!searchQuery.trim()) return organizations;
@@ -110,28 +171,154 @@ function OrganizationSelector({
110
171
  }
111
172
  onValueChange?.(orgId);
112
173
  setSearchQuery(''); // Clear search on selection
174
+ setOpen(false); // Close modal on selection
113
175
  },
114
176
  [value, onValueChange]
115
177
  );
116
178
 
117
179
  const triggerSizeClass = size === 'sm' ? 'h-7' : 'h-8';
118
180
 
181
+ // Determine content state
182
+ const hasError = Boolean(error);
183
+ const isEmpty = organizations.length === 0;
184
+ const hasNoResults = filteredOrganizations.length === 0 && !isEmpty;
185
+
186
+ const triggerButton = (
187
+ <button
188
+ ref={triggerRef}
189
+ type="button"
190
+ data-slot="organization-selector-trigger"
191
+ data-size={size}
192
+ disabled={disabled || loading}
193
+ onClick={modal ? () => setOpen(true) : undefined}
194
+ className={`gap-1.5 rounded-lg border border-transparent bg-transparent py-2 pr-2.5 pl-2.5 text-sm transition-colors select-none hover:bg-accent [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 ${triggerSizeClass}`}
195
+ >
196
+ {loading ? (
197
+ <>
198
+ <LoadingSpinner />
199
+ <span className="text-muted-foreground">Loading...</span>
200
+ </>
201
+ ) : selectedOrg ? (
202
+ <OrganizationItemContent org={selectedOrg} maxWidth={maxWidth} />
203
+ ) : (
204
+ <span className="text-muted-foreground">{placeholder}</span>
205
+ )}
206
+ {!loading && <ChevronDown className="text-muted-foreground size-3.5 shrink-0" />}
207
+ </button>
208
+ );
209
+
210
+ // Modal mode: full-screen dialog like Cmd+K
211
+ if (modal) {
212
+ return (
213
+ <>
214
+ {triggerButton}
215
+ <DialogPrimitive.Root open={open} onOpenChange={setOpen}>
216
+ <DialogPrimitive.Portal>
217
+ <DialogPrimitive.Backdrop
218
+ data-slot="dialog-overlay"
219
+ className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50"
220
+ />
221
+ <DialogPrimitive.Popup
222
+ data-slot="command-dialog-content"
223
+ className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 rounded-xl p-0 ring-1 duration-100 sm:max-w-lg group/dialog-content fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-hidden outline-none"
224
+ >
225
+ <div className="sr-only">
226
+ <DialogPrimitive.Title>Select Organization</DialogPrimitive.Title>
227
+ <DialogPrimitive.Description>
228
+ Search and select an organization from the list
229
+ </DialogPrimitive.Description>
230
+ </div>
231
+ <CommandPrimitive
232
+ data-slot="command"
233
+ className="bg-popover text-popover-foreground rounded-xl p-1 flex size-full flex-col overflow-hidden"
234
+ filter={(value, search) => {
235
+ const org = organizations.find((o) => o.id === value);
236
+ if (!org) return 0;
237
+ const query = search.toLowerCase();
238
+ if (org.id.toLowerCase().includes(query)) return 1;
239
+ if (org.name.toLowerCase().includes(query)) return 1;
240
+ return 0;
241
+ }}
242
+ >
243
+ {/* Search Input */}
244
+ <div data-slot="command-input-wrapper" className="p-1 pb-0">
245
+ <div
246
+ data-slot="input-group"
247
+ role="group"
248
+ className="border-input/30 bg-input/30 h-8 rounded-lg border shadow-none transition-[color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] group/input-group relative flex w-full min-w-0 items-center outline-none"
249
+ >
250
+ <CommandPrimitive.Input
251
+ data-slot="command-input"
252
+ placeholder={searchPlaceholder}
253
+ className="w-full flex-1 bg-transparent px-2.5 py-1 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
254
+ />
255
+ <div
256
+ data-slot="input-group-addon"
257
+ data-align="inline-end"
258
+ className="text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 pr-2 text-sm font-medium select-none"
259
+ >
260
+ <Search className="size-4 shrink-0 opacity-50" />
261
+ </div>
262
+ </div>
263
+ </div>
264
+
265
+ {/* Organization List */}
266
+ <CommandPrimitive.List
267
+ data-slot="command-list"
268
+ className="no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto"
269
+ >
270
+ {loading ? (
271
+ <EmptyState icon={<LoadingSpinner />} text={loadingText} />
272
+ ) : hasError ? (
273
+ <EmptyState text={error!} />
274
+ ) : isEmpty ? (
275
+ <EmptyState text={emptyListText} />
276
+ ) : (
277
+ <CommandPrimitive.Group data-slot="command-group" className="text-foreground overflow-hidden p-1">
278
+ <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm">
279
+ {emptySearchText}
280
+ </CommandPrimitive.Empty>
281
+ {organizations.map((org) => (
282
+ <CommandPrimitive.Item
283
+ key={org.id}
284
+ data-slot="command-item"
285
+ value={org.id}
286
+ onSelect={() => handleValueChange(org.id)}
287
+ className="data-[selected=true]:bg-muted data-[selected=true]:text-foreground relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0"
288
+ >
289
+ <OrganizationItemContent org={org} />
290
+ {selectedValue === org.id && (
291
+ <Checkmark className="ml-auto size-4" />
292
+ )}
293
+ </CommandPrimitive.Item>
294
+ ))}
295
+ </CommandPrimitive.Group>
296
+ )}
297
+ </CommandPrimitive.List>
298
+ </CommandPrimitive>
299
+ </DialogPrimitive.Popup>
300
+ </DialogPrimitive.Portal>
301
+ </DialogPrimitive.Root>
302
+ </>
303
+ );
304
+ }
305
+
306
+ // Dropdown mode (default): inline combobox
119
307
  return (
120
308
  <ComboboxPrimitive.Root value={selectedValue} onValueChange={handleValueChange}>
121
309
  <ComboboxPrimitive.Trigger
310
+ ref={triggerRef}
122
311
  data-slot="organization-selector-trigger"
123
312
  data-size={size}
124
313
  disabled={disabled}
125
- className={`border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-2 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4 flex w-full items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 ${triggerSizeClass}`}
314
+ className={`gap-1.5 rounded-lg border border-transparent bg-transparent py-2 pr-2.5 pl-2.5 text-sm transition-colors select-none hover:bg-accent [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 ${triggerSizeClass}`}
126
315
  >
127
- <span className="flex flex-1 items-center gap-2 truncate">
128
- {selectedOrg ? (
129
- <OrganizationItemContent org={selectedOrg} />
130
- ) : (
131
- <span className="text-muted-foreground">{placeholder}</span>
132
- )}
133
- </span>
134
- <ChevronDown className="text-muted-foreground size-4 shrink-0" />
316
+ {selectedOrg ? (
317
+ <OrganizationItemContent org={selectedOrg} maxWidth={maxWidth} />
318
+ ) : (
319
+ <span className="text-muted-foreground">{placeholder}</span>
320
+ )}
321
+ <ChevronDown className="text-muted-foreground size-3.5 shrink-0" />
135
322
  </ComboboxPrimitive.Trigger>
136
323
 
137
324
  <ComboboxPrimitive.Portal>
@@ -164,10 +351,14 @@ function OrganizationSelector({
164
351
  data-slot="organization-selector-list"
165
352
  className="no-scrollbar max-h-64 scroll-py-1 overflow-y-auto p-1 overscroll-contain"
166
353
  >
167
- {filteredOrganizations.length === 0 ? (
168
- <div className="text-muted-foreground flex w-full justify-center py-6 text-center text-sm">
169
- {emptyText}
170
- </div>
354
+ {loading ? (
355
+ <EmptyState icon={<LoadingSpinner />} text={loadingText} />
356
+ ) : hasError ? (
357
+ <EmptyState text={error!} />
358
+ ) : isEmpty ? (
359
+ <EmptyState text={emptyListText} />
360
+ ) : hasNoResults ? (
361
+ <EmptyState text={emptySearchText} />
171
362
  ) : (
172
363
  filteredOrganizations.map((org) => (
173
364
  <ComboboxPrimitive.Item
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
 
4
4
  import { cn } from '../../../lib/utils';
5
5
  import { Stack } from '../atoms/stack';
6
+ import { Skeleton } from '../atoms/skeleton';
6
7
 
7
8
  const pageLayoutVariants = cva('min-h-full bg-background text-foreground', {
8
9
  variants: {
@@ -48,6 +49,29 @@ interface PageLayoutProps
48
49
  maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
49
50
  /** Vertical gap between children. Defaults to 'lg' (gap-6). */
50
51
  gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '0' | '1' | '2' | '3' | '4' | '6' | '8';
52
+ /** Whether the page is loading. Shows skeleton placeholder when true. */
53
+ loading?: boolean;
54
+ }
55
+
56
+ function PageLayoutSkeleton() {
57
+ return (
58
+ <Stack gap="lg">
59
+ {/* Header skeleton */}
60
+ <div className="space-y-2">
61
+ <Skeleton style={{ width: '30%', height: 24 }} />
62
+ <Skeleton style={{ width: '50%', height: 16 }} />
63
+ </div>
64
+ {/* Content skeleton */}
65
+ <div className="space-y-4">
66
+ <Skeleton style={{ width: '100%', height: 200 }} />
67
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
68
+ <Skeleton style={{ width: '100%', height: 120 }} />
69
+ <Skeleton style={{ width: '100%', height: 120 }} />
70
+ <Skeleton style={{ width: '100%', height: 120 }} />
71
+ </div>
72
+ </div>
73
+ </Stack>
74
+ );
51
75
  }
52
76
 
53
77
  function PageLayout({
@@ -56,13 +80,16 @@ function PageLayout({
56
80
  container = true,
57
81
  maxWidth,
58
82
  gap = 'lg',
83
+ loading = false,
59
84
  children,
60
85
  ...props
61
86
  }: PageLayoutProps) {
62
87
  // For center variant, default to smaller max-width (sm) for auth-style pages
63
88
  const resolvedMaxWidth = maxWidth ?? (variant === 'center' ? 'sm' : 'xl');
64
89
 
65
- const content = (
90
+ const content = loading ? (
91
+ <PageLayoutSkeleton />
92
+ ) : (
66
93
  <Stack gap={gap}>
67
94
  {children}
68
95
  </Stack>
@@ -72,6 +99,7 @@ function PageLayout({
72
99
  <div
73
100
  data-slot="page-layout"
74
101
  data-variant={variant}
102
+ data-loading={loading || undefined}
75
103
  className={pageLayoutVariants({ variant, padding })}
76
104
  {...props}
77
105
  >
@@ -1,190 +0,0 @@
1
- import * as React from 'react';
2
- import { Menu as MenuPrimitive } from '@base-ui/react/menu';
3
- import { cva, type VariantProps } from 'class-variance-authority';
4
- import { ChevronDown } from '@carbon/icons-react';
5
-
6
- import { Spinner } from '../atoms/spinner';
7
- import {
8
- DropdownMenuContent,
9
- DropdownMenuItem,
10
- DropdownMenuSeparator,
11
- } from '../organisms/dropdown-menu';
12
-
13
- const splitButtonVariants = cva(
14
- 'inline-flex items-stretch rounded-md',
15
- {
16
- variants: {
17
- variant: {
18
- default: 'bg-primary text-primary-foreground [&_[data-slot=split-button-divider]]:bg-primary-foreground/20',
19
- outline: 'border border-border bg-background [&_[data-slot=split-button-divider]]:bg-border',
20
- secondary: 'bg-secondary text-secondary-foreground [&_[data-slot=split-button-divider]]:bg-secondary-foreground/20',
21
- ghost: '[&_[data-slot=split-button-divider]]:bg-border',
22
- destructive: 'bg-destructive/10 text-destructive [&_[data-slot=split-button-divider]]:bg-destructive/20',
23
- },
24
- size: {
25
- xs: 'h-5 text-[11px]',
26
- sm: 'h-6 text-xs',
27
- default: 'h-7 text-[13px]',
28
- lg: 'h-8 text-[13px]',
29
- },
30
- },
31
- defaultVariants: {
32
- variant: 'default',
33
- size: 'default',
34
- },
35
- }
36
- );
37
-
38
- const splitButtonMainVariants = cva(
39
- 'inline-flex items-center justify-center gap-1 font-medium leading-none rounded-l-md transition-all duration-200 ease-out outline-none select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
40
- {
41
- variants: {
42
- variant: {
43
- default: 'hover:bg-primary/90 active:bg-primary/80',
44
- outline: 'hover:bg-muted active:bg-muted/80',
45
- secondary: 'hover:bg-secondary/80 active:bg-secondary/70',
46
- ghost: 'hover:bg-accent active:bg-accent/80',
47
- destructive: 'hover:bg-destructive/15 active:bg-destructive/20',
48
- },
49
- size: {
50
- xs: "px-1.5 [&_svg:not([class*='size-'])]:size-3",
51
- sm: "px-2 [&_svg:not([class*='size-'])]:size-3.5",
52
- default: "px-2 [&_svg:not([class*='size-'])]:size-4",
53
- lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
54
- },
55
- },
56
- defaultVariants: {
57
- variant: 'default',
58
- size: 'default',
59
- },
60
- }
61
- );
62
-
63
- const splitButtonTriggerVariants = cva(
64
- 'inline-flex items-center justify-center rounded-r-md transition-all duration-200 ease-out outline-none select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
65
- {
66
- variants: {
67
- variant: {
68
- default: 'hover:bg-primary/90 active:bg-primary/80',
69
- outline: 'hover:bg-muted active:bg-muted/80',
70
- secondary: 'hover:bg-secondary/80 active:bg-secondary/70',
71
- ghost: 'hover:bg-accent active:bg-accent/80',
72
- destructive: 'hover:bg-destructive/15 active:bg-destructive/20',
73
- },
74
- size: {
75
- xs: "w-5 [&_svg:not([class*='size-'])]:size-3",
76
- sm: "w-6 [&_svg:not([class*='size-'])]:size-3.5",
77
- default: "w-7 [&_svg:not([class*='size-'])]:size-4",
78
- lg: "w-8 [&_svg:not([class*='size-'])]:size-4",
79
- },
80
- },
81
- defaultVariants: {
82
- variant: 'default',
83
- size: 'default',
84
- },
85
- }
86
- );
87
-
88
- type SplitButtonAction = {
89
- /** Unique identifier for the action */
90
- id: string;
91
- /** Label to display in the dropdown */
92
- label: React.ReactNode;
93
- /** Callback when action is clicked */
94
- onClick?: () => void;
95
- /** Whether the action is destructive */
96
- variant?: 'default' | 'destructive';
97
- /** Icon to show before the label */
98
- icon?: React.ReactNode;
99
- /** Whether to show a separator after this item */
100
- separator?: boolean;
101
- /** Whether this action is disabled */
102
- disabled?: boolean;
103
- };
104
-
105
- type SplitButtonProps = VariantProps<typeof splitButtonVariants> & {
106
- /** Content of the main button */
107
- children: React.ReactNode;
108
- /** Additional actions shown in the dropdown */
109
- actions: SplitButtonAction[];
110
- /** Callback when main button is clicked */
111
- onClick?: () => void;
112
- /** Whether the button is disabled */
113
- disabled?: boolean;
114
- /** Show loading spinner and disable button */
115
- loading?: boolean;
116
- /** Icon to show on the left side of the button */
117
- iconLeft?: React.ReactNode;
118
- /** Dropdown menu alignment */
119
- menuAlign?: 'start' | 'center' | 'end';
120
- /** Side of the trigger to show the dropdown */
121
- menuSide?: 'top' | 'bottom';
122
- };
123
-
124
- function SplitButton({
125
- children,
126
- actions,
127
- onClick,
128
- variant = 'default',
129
- size = 'default',
130
- menuAlign = 'end',
131
- menuSide = 'bottom',
132
- disabled,
133
- loading,
134
- iconLeft,
135
- }: SplitButtonProps) {
136
- const isDisabled = disabled || loading;
137
-
138
- return (
139
- <div
140
- data-slot="split-button"
141
- className={splitButtonVariants({ variant, size })}
142
- >
143
- <button
144
- type="button"
145
- data-slot="split-button-main"
146
- onClick={onClick}
147
- disabled={isDisabled}
148
- className={splitButtonMainVariants({ variant, size })}
149
- >
150
- {loading ? (
151
- <Spinner />
152
- ) : iconLeft ? (
153
- <span data-icon="inline-start">{iconLeft}</span>
154
- ) : null}
155
- {children}
156
- </button>
157
- <span
158
- data-slot="split-button-divider"
159
- className="w-px self-stretch"
160
- aria-hidden="true"
161
- />
162
- <MenuPrimitive.Root>
163
- <MenuPrimitive.Trigger
164
- disabled={isDisabled}
165
- className={splitButtonTriggerVariants({ variant, size })}
166
- >
167
- <ChevronDown />
168
- </MenuPrimitive.Trigger>
169
- <DropdownMenuContent align={menuAlign} side={menuSide}>
170
- {actions.map((action) => (
171
- <React.Fragment key={action.id}>
172
- <DropdownMenuItem
173
- onClick={action.onClick}
174
- variant={action.variant}
175
- disabled={action.disabled}
176
- >
177
- {action.icon}
178
- {action.label}
179
- </DropdownMenuItem>
180
- {action.separator && <DropdownMenuSeparator />}
181
- </React.Fragment>
182
- ))}
183
- </DropdownMenuContent>
184
- </MenuPrimitive.Root>
185
- </div>
186
- );
187
- }
188
-
189
- export { SplitButton };
190
- export type { SplitButtonProps, SplitButtonAction };
@@ -1,369 +0,0 @@
1
- import * as React from 'react';
2
- import { cva, type VariantProps } from 'class-variance-authority';
3
- import {
4
- Time,
5
- Information,
6
- CheckmarkOutline,
7
- Checkmark,
8
- Close,
9
- } from '@carbon/icons-react';
10
-
11
- import { Button } from '../atoms/button';
12
- import { Stack, HStack } from '../atoms/stack';
13
- import { Text } from '../atoms/text';
14
- import { SplitButton, type SplitButtonAction } from '../molecules/split-button';
15
- import {
16
- AlertDialog,
17
- AlertDialogAction,
18
- AlertDialogCancel,
19
- AlertDialogContent,
20
- AlertDialogDescription,
21
- AlertDialogFooter,
22
- AlertDialogHeader,
23
- AlertDialogTitle,
24
- } from './alert-dialog';
25
-
26
- const approvalBannerVariants = cva(
27
- 'rounded-lg border border-l-4 bg-background p-4',
28
- {
29
- variants: {
30
- variant: {
31
- warning: 'border-l-warning border-border',
32
- info: 'border-l-info border-border',
33
- default: 'border-l-primary border-border',
34
- },
35
- layout: {
36
- stacked: '',
37
- inline:
38
- '[&>[data-slot=stack]]:flex-row [&>[data-slot=stack]]:items-center [&>[data-slot=stack]]:justify-between [&_[data-slot=approval-banner-content]]:min-w-0 [&_[data-slot=approval-banner-content]]:flex-1 [&_[data-slot=approval-banner-actions]]:shrink-0 [&_[data-slot=approval-banner-description]]:truncate',
39
- },
40
- },
41
- defaultVariants: {
42
- variant: 'warning',
43
- layout: 'stacked',
44
- },
45
- }
46
- );
47
-
48
- const textVariantMap = {
49
- warning: 'warning',
50
- info: 'info',
51
- default: 'primary',
52
- } as const;
53
-
54
- const iconMap = {
55
- warning: Time,
56
- info: Information,
57
- default: CheckmarkOutline,
58
- };
59
-
60
- type ConfirmationConfig = {
61
- /** Title of the confirmation dialog */
62
- title: string;
63
- /** Description of the confirmation dialog */
64
- description?: string;
65
- /** Custom content to render in the dialog body (below description) */
66
- content?: React.ReactNode;
67
- /** Confirm button text */
68
- confirmText?: string;
69
- /** Cancel button text */
70
- cancelText?: string;
71
- /** Called when the dialog is cancelled/dismissed */
72
- onCancel?: () => void;
73
- };
74
-
75
- type ApprovalBannerProps = VariantProps<typeof approvalBannerVariants> & {
76
- /** Title of the approval banner */
77
- title: string;
78
- /** Description text */
79
- description: React.ReactNode;
80
- /** Callback when approve is confirmed */
81
- onApprove?: () => void | Promise<void>;
82
- /** Callback when reject is confirmed */
83
- onReject?: () => void | Promise<void>;
84
- /** Custom approve button text */
85
- approveText?: string;
86
- /** Custom reject button text */
87
- rejectText?: string;
88
- /** Whether approve action is loading */
89
- approveLoading?: boolean;
90
- /** Whether reject action is loading */
91
- rejectLoading?: boolean;
92
- /** Custom icon to display */
93
- icon?: React.ReactNode;
94
- /** Hide the reject button */
95
- hideReject?: boolean;
96
- /** Additional actions to show in dropdown (inline layout only) */
97
- additionalActions?: SplitButtonAction[];
98
- /** Confirmation dialog config for approve action. If provided, shows dialog before calling onApprove */
99
- approveConfirmation?: ConfirmationConfig;
100
- /** Confirmation dialog config for reject action. If provided, shows dialog before calling onReject */
101
- rejectConfirmation?: ConfirmationConfig;
102
- };
103
-
104
- function ApprovalBanner({
105
- variant = 'warning',
106
- layout = 'stacked',
107
- title,
108
- description,
109
- onApprove,
110
- onReject,
111
- approveText = 'Approve',
112
- rejectText = 'Reject',
113
- approveLoading = false,
114
- rejectLoading = false,
115
- icon,
116
- hideReject = false,
117
- additionalActions = [],
118
- approveConfirmation,
119
- rejectConfirmation,
120
- }: ApprovalBannerProps) {
121
- const [approveDialogOpen, setApproveDialogOpen] = React.useState(false);
122
- const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false);
123
- const [isApproving, setIsApproving] = React.useState(false);
124
- const [isRejecting, setIsRejecting] = React.useState(false);
125
-
126
- const IconComponent = iconMap[variant ?? 'warning'];
127
- const textVariant = textVariantMap[variant ?? 'warning'];
128
- const isLoading = approveLoading || rejectLoading || isApproving || isRejecting;
129
-
130
- const handleApproveClick = () => {
131
- if (approveConfirmation) {
132
- setApproveDialogOpen(true);
133
- } else {
134
- onApprove?.();
135
- }
136
- };
137
-
138
- const handleRejectClick = () => {
139
- if (rejectConfirmation) {
140
- setRejectDialogOpen(true);
141
- } else {
142
- onReject?.();
143
- }
144
- };
145
-
146
- const handleApproveConfirm = async () => {
147
- setIsApproving(true);
148
- try {
149
- await onApprove?.();
150
- } finally {
151
- setIsApproving(false);
152
- setApproveDialogOpen(false);
153
- }
154
- };
155
-
156
- const handleRejectConfirm = async () => {
157
- setIsRejecting(true);
158
- try {
159
- await onReject?.();
160
- } finally {
161
- setIsRejecting(false);
162
- setRejectDialogOpen(false);
163
- }
164
- };
165
-
166
- const handleApproveDialogChange = (open: boolean) => {
167
- setApproveDialogOpen(open);
168
- if (!open && !isApproving) {
169
- approveConfirmation?.onCancel?.();
170
- }
171
- };
172
-
173
- const handleRejectDialogChange = (open: boolean) => {
174
- setRejectDialogOpen(open);
175
- if (!open && !isRejecting) {
176
- rejectConfirmation?.onCancel?.();
177
- }
178
- };
179
-
180
- // Build dropdown actions for inline layout
181
- const dropdownActions: SplitButtonAction[] = React.useMemo(() => {
182
- const actions: SplitButtonAction[] = [];
183
-
184
- if (!hideReject) {
185
- actions.push({
186
- id: 'reject',
187
- label: rejectText,
188
- icon: <Close size={16} />,
189
- onClick: handleRejectClick,
190
- variant: 'destructive',
191
- separator: additionalActions.length > 0,
192
- });
193
- }
194
-
195
- return [...actions, ...additionalActions];
196
- }, [hideReject, rejectText, additionalActions, rejectConfirmation]);
197
-
198
- const isInline = layout === 'inline';
199
-
200
- const bannerContent = isInline ? (
201
- <div
202
- data-slot="approval-banner"
203
- className={approvalBannerVariants({ variant, layout })}
204
- >
205
- <HStack gap="3" align="start">
206
- <HStack gap="3" align="start" data-slot="approval-banner-content">
207
- <Text as="span" variant={textVariant}>
208
- {icon ?? <IconComponent size={20} />}
209
- </Text>
210
- <Stack gap="0">
211
- <Text
212
- size="sm"
213
- weight="medium"
214
- leading="tight"
215
- variant={textVariant}
216
- >
217
- {title}
218
- </Text>
219
- <Text
220
- as="span"
221
- size="sm"
222
- variant="muted"
223
- data-slot="approval-banner-description"
224
- >
225
- {description}
226
- </Text>
227
- </Stack>
228
- </HStack>
229
- <HStack data-slot="approval-banner-actions">
230
- {dropdownActions.length > 0 ? (
231
- <SplitButton
232
- onClick={handleApproveClick}
233
- disabled={isLoading}
234
- loading={approveLoading || isApproving}
235
- iconLeft={<Checkmark size={16} />}
236
- actions={dropdownActions}
237
- >
238
- {approveText}
239
- </SplitButton>
240
- ) : (
241
- <Button
242
- onClick={handleApproveClick}
243
- disabled={isLoading}
244
- loading={approveLoading || isApproving}
245
- iconLeft={<Checkmark size={16} />}
246
- >
247
- {approveText}
248
- </Button>
249
- )}
250
- </HStack>
251
- </HStack>
252
- </div>
253
- ) : (
254
- <div
255
- data-slot="approval-banner"
256
- className={approvalBannerVariants({ variant, layout })}
257
- >
258
- <HStack gap="3" align="start">
259
- <Text as="span" variant={textVariant}>
260
- {icon ?? <IconComponent size={20} />}
261
- </Text>
262
- <Stack gap="3">
263
- <Stack gap="1">
264
- <Text
265
- size="sm"
266
- weight="medium"
267
- leading="tight"
268
- variant={textVariant}
269
- >
270
- {title}
271
- </Text>
272
- <Text size="sm" variant="muted">
273
- {description}
274
- </Text>
275
- </Stack>
276
- <HStack gap="3">
277
- {!hideReject && (
278
- <Button
279
- variant="outline"
280
- onClick={handleRejectClick}
281
- disabled={isLoading}
282
- loading={rejectLoading || isRejecting}
283
- iconLeft={<Close size={16} />}
284
- >
285
- {rejectText}
286
- </Button>
287
- )}
288
- <Button
289
- onClick={handleApproveClick}
290
- disabled={isLoading}
291
- loading={approveLoading || isApproving}
292
- iconLeft={<Checkmark size={16} />}
293
- >
294
- {approveText}
295
- </Button>
296
- </HStack>
297
- </Stack>
298
- </HStack>
299
- </div>
300
- );
301
-
302
- return (
303
- <>
304
- {bannerContent}
305
-
306
- {/* Approve Confirmation Dialog */}
307
- <AlertDialog open={approveDialogOpen} onOpenChange={handleApproveDialogChange}>
308
- <AlertDialogContent>
309
- <AlertDialogHeader>
310
- <AlertDialogTitle>
311
- {approveConfirmation?.title ?? 'Confirm Approval'}
312
- </AlertDialogTitle>
313
- {approveConfirmation?.description && (
314
- <AlertDialogDescription>
315
- {approveConfirmation.description}
316
- </AlertDialogDescription>
317
- )}
318
- </AlertDialogHeader>
319
- {approveConfirmation?.content}
320
- <AlertDialogFooter>
321
- <AlertDialogCancel disabled={isApproving}>
322
- {approveConfirmation?.cancelText ?? 'Cancel'}
323
- </AlertDialogCancel>
324
- <AlertDialogAction
325
- onClick={handleApproveConfirm}
326
- loading={isApproving}
327
- disabled={isApproving}
328
- >
329
- {approveConfirmation?.confirmText ?? 'Approve'}
330
- </AlertDialogAction>
331
- </AlertDialogFooter>
332
- </AlertDialogContent>
333
- </AlertDialog>
334
-
335
- {/* Reject Confirmation Dialog */}
336
- <AlertDialog open={rejectDialogOpen} onOpenChange={handleRejectDialogChange}>
337
- <AlertDialogContent>
338
- <AlertDialogHeader>
339
- <AlertDialogTitle>
340
- {rejectConfirmation?.title ?? 'Confirm Rejection'}
341
- </AlertDialogTitle>
342
- {rejectConfirmation?.description && (
343
- <AlertDialogDescription>
344
- {rejectConfirmation.description}
345
- </AlertDialogDescription>
346
- )}
347
- </AlertDialogHeader>
348
- {rejectConfirmation?.content}
349
- <AlertDialogFooter>
350
- <AlertDialogCancel disabled={isRejecting}>
351
- {rejectConfirmation?.cancelText ?? 'Cancel'}
352
- </AlertDialogCancel>
353
- <AlertDialogAction
354
- variant="destructive"
355
- onClick={handleRejectConfirm}
356
- loading={isRejecting}
357
- disabled={isRejecting}
358
- >
359
- {rejectConfirmation?.confirmText ?? 'Reject'}
360
- </AlertDialogAction>
361
- </AlertDialogFooter>
362
- </AlertDialogContent>
363
- </AlertDialog>
364
- </>
365
- );
366
- }
367
-
368
- export { ApprovalBanner };
369
- export type { ApprovalBannerProps, ConfirmationConfig };