@trycompai/design-system 1.0.13 → 1.0.16

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.13",
3
+ "version": "1.0.16",
4
4
  "description": "Design system for Comp AI - shadcn-style components with Tailwind CSS",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,6 +15,8 @@ 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',
18
20
  },
19
21
  weight: {
20
22
  normal: 'font-normal',
@@ -10,7 +10,7 @@ function Textarea({
10
10
  <textarea
11
11
  data-slot="textarea"
12
12
  data-size={size}
13
- className="border-input dark:bg-input/30 focus-visible:border-primary focus-visible:ring-primary/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-sm border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 max-w-full outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=sm]:w-xs data-[size=default]:w-md data-[size=lg]:w-xl data-[size=full]:w-full"
13
+ className="border-input dark:bg-input/30 focus-visible:border-primary focus-visible:ring-primary/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-sm border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 min-w-0 w-full resize-y outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=sm]:max-w-xs data-[size=default]:max-w-md data-[size=lg]:max-w-xl"
14
14
  {...props}
15
15
  />
16
16
  );
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { Search } from '@carbon/icons-react';
4
+ import * as React from 'react';
5
+
6
+ // ============================================================================
7
+ // DataTableHeader
8
+ // ============================================================================
9
+
10
+ export interface DataTableHeaderProps {
11
+ children?: React.ReactNode;
12
+ }
13
+
14
+ function DataTableHeader({ children }: DataTableHeaderProps) {
15
+ return (
16
+ <div
17
+ data-slot="data-table-header"
18
+ className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
19
+ >
20
+ {children}
21
+ </div>
22
+ );
23
+ }
24
+
25
+ // ============================================================================
26
+ // DataTableSearch
27
+ // ============================================================================
28
+
29
+ export interface DataTableSearchProps {
30
+ /** Placeholder text for the search input */
31
+ placeholder?: string;
32
+ /** Current search value */
33
+ value?: string;
34
+ /** Callback when search value changes */
35
+ onChange?: (value: string) => void;
36
+ }
37
+
38
+ function DataTableSearch({
39
+ placeholder = 'Search...',
40
+ value,
41
+ onChange,
42
+ }: DataTableSearchProps) {
43
+ return (
44
+ <div
45
+ data-slot="data-table-search"
46
+ className="relative flex-1 max-w-sm"
47
+ >
48
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
49
+ <input
50
+ type="text"
51
+ placeholder={placeholder}
52
+ value={value}
53
+ onChange={(e) => onChange?.(e.target.value)}
54
+ className="w-full h-9 pl-9 pr-3 rounded-lg border border-input bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/50 focus:border-ring transition-colors"
55
+ />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // ============================================================================
61
+ // DataTableFilters
62
+ // ============================================================================
63
+
64
+ export interface DataTableFiltersProps {
65
+ children?: React.ReactNode;
66
+ }
67
+
68
+ function DataTableFilters({ children }: DataTableFiltersProps) {
69
+ return (
70
+ <div
71
+ data-slot="data-table-filters"
72
+ className="flex items-center gap-2 shrink-0"
73
+ >
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export { DataTableHeader, DataTableSearch, DataTableFilters };
@@ -6,6 +6,7 @@ export * from './button-group';
6
6
  export * from './card';
7
7
  export * from './collapsible';
8
8
  export * from './command-search';
9
+ export * from './data-table-header';
9
10
  export * from './empty';
10
11
  export * from './field';
11
12
  export * from './grid';
@@ -22,6 +23,7 @@ export * from './scroll-area';
22
23
  export * from './section';
23
24
  export * from './select';
24
25
  export * from './settings';
26
+ export * from './split-button';
25
27
  export * from './table';
26
28
  export * from './tabs';
27
29
  export * from './theme-switcher';
@@ -0,0 +1,190 @@
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 };
@@ -0,0 +1,369 @@
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 };
@@ -1,4 +1,5 @@
1
1
  export * from './alert-dialog';
2
+ export * from './approval-banner';
2
3
  export * from './app-shell';
3
4
  export * from './calendar';
4
5
  export * from './carousel';
@@ -11,6 +12,7 @@ export * from './drawer';
11
12
  export * from './dropdown-menu';
12
13
  export * from './menubar';
13
14
  export * from './navigation-menu';
15
+ export * from './organization-selector';
14
16
  export * from './page-layout';
15
17
  export * from './sheet';
16
18
  export * from './sidebar';
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import { Combobox as ComboboxPrimitive } from '@base-ui/react';
4
+ import { Checkmark, ChevronDown, Search } from '@carbon/icons-react';
5
+ import * as React from 'react';
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ export interface Organization {
12
+ /** Unique organization identifier (searchable) */
13
+ id: string;
14
+ /** Organization display name (searchable) */
15
+ name: string;
16
+ /** Optional icon or avatar to display */
17
+ icon?: React.ReactNode;
18
+ /** Optional brand color for the indicator dot (e.g., "#10b981" or "bg-green-500") */
19
+ color?: string;
20
+ }
21
+
22
+ export interface OrganizationSelectorProps {
23
+ /** List of organizations to display */
24
+ organizations: Organization[];
25
+ /** Currently selected organization ID (controlled) */
26
+ value?: string;
27
+ /** Default selected organization ID (uncontrolled) */
28
+ defaultValue?: string;
29
+ /** Callback when selection changes */
30
+ onValueChange?: (organizationId: string) => void;
31
+ /** Placeholder text when nothing is selected */
32
+ placeholder?: string;
33
+ /** Search input placeholder */
34
+ searchPlaceholder?: string;
35
+ /** Text shown when no results match search */
36
+ emptyText?: string;
37
+ /** Whether the selector is disabled */
38
+ disabled?: boolean;
39
+ /** Size variant for the trigger */
40
+ size?: 'sm' | 'default';
41
+ }
42
+
43
+ // ============================================================================
44
+ // Internal Components
45
+ // ============================================================================
46
+
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';
56
+
57
+ return <span className={className} style={style} />;
58
+ }
59
+
60
+ function OrganizationItemContent({ org }: { org: Organization }) {
61
+ return (
62
+ <>
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>
69
+ </>
70
+ );
71
+ }
72
+
73
+ // ============================================================================
74
+ // Main Component
75
+ // ============================================================================
76
+
77
+ function OrganizationSelector({
78
+ organizations,
79
+ value,
80
+ defaultValue,
81
+ onValueChange,
82
+ placeholder = 'Select organization',
83
+ searchPlaceholder = 'Search by name or ID...',
84
+ emptyText = 'No organizations found',
85
+ disabled = false,
86
+ size = 'default',
87
+ }: OrganizationSelectorProps) {
88
+ const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
89
+ const [searchQuery, setSearchQuery] = React.useState('');
90
+
91
+ const selectedValue = value ?? internalValue;
92
+ const selectedOrg = organizations.find((org) => org.id === selectedValue);
93
+
94
+ // Filter organizations by ID or name (case-insensitive)
95
+ const filteredOrganizations = React.useMemo(() => {
96
+ if (!searchQuery.trim()) return organizations;
97
+ const query = searchQuery.toLowerCase();
98
+ return organizations.filter(
99
+ (org) =>
100
+ org.id.toLowerCase().includes(query) ||
101
+ org.name.toLowerCase().includes(query)
102
+ );
103
+ }, [organizations, searchQuery]);
104
+
105
+ const handleValueChange = React.useCallback(
106
+ (newValue: string | null) => {
107
+ const orgId = newValue ?? '';
108
+ if (value === undefined) {
109
+ setInternalValue(orgId);
110
+ }
111
+ onValueChange?.(orgId);
112
+ setSearchQuery(''); // Clear search on selection
113
+ },
114
+ [value, onValueChange]
115
+ );
116
+
117
+ const triggerSizeClass = size === 'sm' ? 'h-7' : 'h-8';
118
+
119
+ return (
120
+ <ComboboxPrimitive.Root value={selectedValue} onValueChange={handleValueChange}>
121
+ <ComboboxPrimitive.Trigger
122
+ data-slot="organization-selector-trigger"
123
+ data-size={size}
124
+ 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}`}
126
+ >
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" />
135
+ </ComboboxPrimitive.Trigger>
136
+
137
+ <ComboboxPrimitive.Portal>
138
+ <ComboboxPrimitive.Positioner
139
+ side="bottom"
140
+ sideOffset={4}
141
+ align="start"
142
+ className="isolate z-50"
143
+ >
144
+ <ComboboxPrimitive.Popup
145
+ data-slot="organization-selector-content"
146
+ className="bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 max-h-80 min-w-56 overflow-hidden rounded-lg shadow-md ring-1 duration-100 group/org-selector relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin)"
147
+ >
148
+ {/* Search Input */}
149
+ <div className="border-b border-border p-2">
150
+ <div className="bg-muted/50 flex items-center gap-2 rounded-md px-2.5">
151
+ <Search className="text-muted-foreground size-4 shrink-0" />
152
+ <ComboboxPrimitive.Input
153
+ data-slot="organization-selector-input"
154
+ placeholder={searchPlaceholder}
155
+ value={searchQuery}
156
+ onChange={(e) => setSearchQuery(e.target.value)}
157
+ className="h-8 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
158
+ />
159
+ </div>
160
+ </div>
161
+
162
+ {/* Organization List */}
163
+ <ComboboxPrimitive.List
164
+ data-slot="organization-selector-list"
165
+ className="no-scrollbar max-h-64 scroll-py-1 overflow-y-auto p-1 overscroll-contain"
166
+ >
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>
171
+ ) : (
172
+ filteredOrganizations.map((org) => (
173
+ <ComboboxPrimitive.Item
174
+ key={org.id}
175
+ data-slot="organization-selector-item"
176
+ value={org.id}
177
+ className="data-highlighted:bg-accent data-highlighted:text-accent-foreground gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
178
+ >
179
+ <OrganizationItemContent org={org} />
180
+ <ComboboxPrimitive.ItemIndicator
181
+ render={
182
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
183
+ }
184
+ >
185
+ <Checkmark className="size-4" />
186
+ </ComboboxPrimitive.ItemIndicator>
187
+ </ComboboxPrimitive.Item>
188
+ ))
189
+ )}
190
+ </ComboboxPrimitive.List>
191
+ </ComboboxPrimitive.Popup>
192
+ </ComboboxPrimitive.Positioner>
193
+ </ComboboxPrimitive.Portal>
194
+ </ComboboxPrimitive.Root>
195
+ );
196
+ }
197
+
198
+ export { OrganizationSelector };