@trycompai/design-system 1.0.17 → 1.0.19

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.19",
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",
@@ -136,6 +136,16 @@ function SelectSeparator({ ...props }: Omit<SelectPrimitive.Separator.Props, 'cl
136
136
  );
137
137
  }
138
138
 
139
+ function SelectEmpty({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
140
+ return (
141
+ <div
142
+ data-slot="select-empty"
143
+ className="text-muted-foreground py-6 text-center text-sm"
144
+ {...props}
145
+ />
146
+ );
147
+ }
148
+
139
149
  function SelectScrollUpButton({
140
150
  ...props
141
151
  }: Omit<React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>, 'className'>) {
@@ -167,6 +177,7 @@ function SelectScrollDownButton({
167
177
  export {
168
178
  Select,
169
179
  SelectContent,
180
+ SelectEmpty,
170
181
  SelectGroup,
171
182
  SelectItem,
172
183
  SelectLabel,
@@ -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
  >