@trycompai/design-system 1.0.14 → 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 +1 -1
- package/src/components/atoms/text.tsx +2 -0
- package/src/components/molecules/data-table-header.tsx +79 -0
- package/src/components/molecules/index.ts +2 -0
- package/src/components/molecules/split-button.tsx +190 -0
- package/src/components/organisms/approval-banner.tsx +369 -0
- package/src/components/organisms/index.ts +2 -0
- package/src/components/organisms/organization-selector.tsx +198 -0
package/package.json
CHANGED
|
@@ -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 };
|