@trycompai/design-system 1.0.33 → 1.0.35

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/README.md CHANGED
@@ -101,7 +101,7 @@ The design system includes:
101
101
 
102
102
  ## Theme Switching
103
103
 
104
- The design system uses CSS class-based theming. Dark mode is enabled by adding the `.dark` class to the `<html>` element.
104
+ The design system uses CSS class-based theming. Light mode is the default. Dark mode is enabled by adding the `.dark` class to the `<html>` element.
105
105
 
106
106
  ### Theme Components
107
107
 
@@ -175,7 +175,7 @@ export default function RootLayout({ children }) {
175
175
  return (
176
176
  <html lang="en" suppressHydrationWarning>
177
177
  <body>
178
- <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
178
+ <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
179
179
  {children}
180
180
  </ThemeProvider>
181
181
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycompai/design-system",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "Design system for Comp AI - shadcn-style components with Tailwind CSS",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -65,34 +65,41 @@ function SelectContent({
65
65
  align = 'center',
66
66
  alignOffset = 0,
67
67
  alignItemWithTrigger = true,
68
+ portal = true,
69
+ position = 'fixed',
68
70
  ...props
69
71
  }: Omit<SelectPrimitive.Popup.Props, 'className'> &
70
72
  Pick<
71
73
  SelectPrimitive.Positioner.Props,
72
74
  'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
73
- >) {
74
- return (
75
- <SelectPrimitive.Portal>
76
- <SelectPrimitive.Positioner
77
- side={side}
78
- sideOffset={sideOffset}
79
- align={align}
80
- alignOffset={alignOffset}
81
- alignItemWithTrigger={alignItemWithTrigger}
82
- className="isolate z-50"
75
+ > & { portal?: boolean; position?: 'fixed' | 'absolute' }) {
76
+ const content = (
77
+ <SelectPrimitive.Positioner
78
+ side={side}
79
+ sideOffset={sideOffset}
80
+ align={align}
81
+ alignOffset={alignOffset}
82
+ alignItemWithTrigger={alignItemWithTrigger}
83
+ className="isolate z-50"
84
+ style={{ position }}
85
+ >
86
+ <SelectPrimitive.Popup
87
+ data-slot="select-content"
88
+ 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 min-w-(--anchor-width) max-w-80 rounded-lg p-1 shadow-md ring-1 duration-100 relative isolate z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto"
89
+ {...props}
83
90
  >
84
- <SelectPrimitive.Popup
85
- data-slot="select-content"
86
- 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 min-w-(--anchor-width) max-w-80 rounded-lg p-1 shadow-md ring-1 duration-100 relative isolate z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto"
87
- {...props}
88
- >
89
- <SelectScrollUpButton />
90
- <SelectPrimitive.List>{children}</SelectPrimitive.List>
91
- <SelectScrollDownButton />
92
- </SelectPrimitive.Popup>
93
- </SelectPrimitive.Positioner>
94
- </SelectPrimitive.Portal>
91
+ <SelectScrollUpButton />
92
+ <SelectPrimitive.List>{children}</SelectPrimitive.List>
93
+ <SelectScrollDownButton />
94
+ </SelectPrimitive.Popup>
95
+ </SelectPrimitive.Positioner>
95
96
  );
97
+
98
+ if (!portal) {
99
+ return content;
100
+ }
101
+
102
+ return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>;
96
103
  }
97
104
 
98
105
  function SelectLabel({ ...props }: Omit<SelectPrimitive.GroupLabel.Props, 'className'>) {
@@ -56,7 +56,7 @@ interface ThemeSwitcherProps
56
56
 
57
57
  function ThemeSwitcher({
58
58
  value: valueProp,
59
- defaultValue = 'system',
59
+ defaultValue = 'light',
60
60
  onChange,
61
61
  showSystem = true,
62
62
  size = 'default',
@@ -5,6 +5,13 @@ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
5
5
  import { Combobox as ComboboxPrimitive } from '@base-ui/react';
6
6
  import { Checkmark, ChevronDown, Search } from '@carbon/icons-react';
7
7
  import * as React from 'react';
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from '../molecules/select';
8
15
  // ============================================================================
9
16
  // Types
10
17
  // ============================================================================
@@ -16,10 +23,22 @@ export interface Organization {
16
23
  name: string;
17
24
  /** Optional icon or avatar to display */
18
25
  icon?: React.ReactNode;
26
+ /** Optional logo URL to display */
27
+ logoUrl?: string;
28
+ /** Optional created at timestamp used for sorting */
29
+ createdAt?: string | number | Date;
19
30
  /** Optional brand color for the indicator dot (e.g., "#10b981" or "bg-green-500") */
20
31
  color?: string;
21
32
  }
22
33
 
34
+ type OrganizationSortKey = 'name' | 'createdAt';
35
+ type OrganizationSortDirection = 'asc' | 'desc';
36
+
37
+ type OrganizationSort = {
38
+ key: OrganizationSortKey;
39
+ direction: OrganizationSortDirection;
40
+ };
41
+
23
42
  export interface OrganizationSelectorProps {
24
43
  /** List of organizations to display */
25
44
  organizations: Organization[];
@@ -59,6 +78,12 @@ export interface OrganizationSelectorProps {
59
78
  createLabel?: string;
60
79
  /** Callback when create action is selected */
61
80
  onCreate?: () => void;
81
+ /** Whether to close the selector after selecting an organization */
82
+ closeOnSelect?: boolean;
83
+ /** Default sort mode for the organization list */
84
+ defaultSort?: OrganizationSort;
85
+ /** Whether to show the sort control */
86
+ enableSorting?: boolean;
62
87
  }
63
88
 
64
89
  // ============================================================================
@@ -100,14 +125,77 @@ function EmptyState({ icon, text }: { icon?: React.ReactNode; text: string }) {
100
125
  }
101
126
 
102
127
  function OrganizationItemContent({ org, maxWidth }: { org: Organization; maxWidth?: string }) {
128
+ const initials = org.name
129
+ .trim()
130
+ .split(/\s+/)
131
+ .slice(0, 2)
132
+ .map((part) => part[0]?.toUpperCase())
133
+ .join('');
134
+
135
+ const leading = org.icon ? (
136
+ <span className="shrink-0">{org.icon}</span>
137
+ ) : org.logoUrl ? (
138
+ <img
139
+ src={org.logoUrl}
140
+ alt={`${org.name} logo`}
141
+ width={20}
142
+ height={20}
143
+ className="size-5 rounded-sm object-contain shrink-0"
144
+ loading="lazy"
145
+ />
146
+ ) : (
147
+ <span
148
+ className="size-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-[10px] font-medium shrink-0"
149
+ aria-hidden
150
+ >
151
+ {initials || '?'}
152
+ </span>
153
+ );
154
+
103
155
  return (
104
156
  <>
105
- {org.icon && <span className="shrink-0">{org.icon}</span>}
106
- <span className="truncate" style={maxWidth ? { maxWidth } : undefined}>{org.name}</span>
157
+ {leading}
158
+ <span className="truncate" style={maxWidth ? { maxWidth } : undefined}>
159
+ {org.name}
160
+ </span>
107
161
  </>
108
162
  );
109
163
  }
110
164
 
165
+ function getSortLabel(sort: OrganizationSort) {
166
+ if (sort.key === 'name') {
167
+ return sort.direction === 'asc' ? 'Alphabetical (A–Z)' : 'Alphabetical (Z–A)';
168
+ }
169
+ return sort.direction === 'asc' ? 'Created (oldest)' : 'Created (newest)';
170
+ }
171
+
172
+ const sortOptions: { value: string; label: string }[] = [
173
+ { value: 'name:asc', label: 'Alphabetical (A–Z)' },
174
+ { value: 'name:desc', label: 'Alphabetical (Z–A)' },
175
+ { value: 'createdAt:desc', label: 'Created (newest)' },
176
+ { value: 'createdAt:asc', label: 'Created (oldest)' },
177
+ ];
178
+
179
+ function parseSortValue(value: string): OrganizationSort {
180
+ const [key, direction] = value.split(':');
181
+ if (key === 'createdAt' && (direction === 'asc' || direction === 'desc')) {
182
+ return { key, direction };
183
+ }
184
+ if (key === 'name' && (direction === 'asc' || direction === 'desc')) {
185
+ return { key, direction };
186
+ }
187
+ return { key: 'name', direction: 'asc' };
188
+ }
189
+
190
+ function getCreatedAtValue(value?: string | number | Date) {
191
+ if (!value) return null;
192
+ if (value instanceof Date) {
193
+ return Number.isNaN(value.getTime()) ? null : value.getTime();
194
+ }
195
+ const parsed = new Date(value).getTime();
196
+ return Number.isNaN(parsed) ? null : parsed;
197
+ }
198
+
111
199
  // ============================================================================
112
200
  // Main Component
113
201
  // ============================================================================
@@ -132,10 +220,14 @@ function OrganizationSelector({
132
220
  footer,
133
221
  createLabel = 'Create organization',
134
222
  onCreate,
223
+ closeOnSelect = false,
224
+ defaultSort = { key: 'name', direction: 'asc' },
225
+ enableSorting = true,
135
226
  }: OrganizationSelectorProps) {
136
227
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
137
228
  const [searchQuery, setSearchQuery] = React.useState('');
138
229
  const [open, setOpen] = React.useState(false);
230
+ const [sort, setSort] = React.useState<OrganizationSort>(defaultSort);
139
231
  const triggerRef = React.useRef<HTMLButtonElement>(null);
140
232
 
141
233
  const selectedValue = value ?? internalValue;
@@ -171,6 +263,24 @@ function OrganizationSelector({
171
263
  );
172
264
  }, [organizations, searchQuery]);
173
265
 
266
+ const sortedOrganizations = React.useMemo(() => {
267
+ const sorted = [...filteredOrganizations];
268
+ sorted.sort((a, b) => {
269
+ if (sort.key === 'name') {
270
+ const comparison = a.name.localeCompare(b.name);
271
+ return sort.direction === 'asc' ? comparison : -comparison;
272
+ }
273
+
274
+ const aTime = getCreatedAtValue(a.createdAt);
275
+ const bTime = getCreatedAtValue(b.createdAt);
276
+ if (aTime === null && bTime === null) return 0;
277
+ if (aTime === null) return 1;
278
+ if (bTime === null) return -1;
279
+ return sort.direction === 'asc' ? aTime - bTime : bTime - aTime;
280
+ });
281
+ return sorted;
282
+ }, [filteredOrganizations, sort]);
283
+
174
284
  const handleValueChange = React.useCallback(
175
285
  (newValue: string | null) => {
176
286
  const orgId = newValue ?? '';
@@ -179,9 +289,11 @@ function OrganizationSelector({
179
289
  }
180
290
  onValueChange?.(orgId);
181
291
  setSearchQuery(''); // Clear search on selection
182
- setOpen(false); // Close modal on selection
292
+ if (closeOnSelect) {
293
+ setOpen(false); // Close modal on selection
294
+ }
183
295
  },
184
- [value, onValueChange]
296
+ [value, onValueChange, closeOnSelect]
185
297
  );
186
298
 
187
299
  const triggerSizeClass = size === 'sm' ? 'h-7' : 'h-8';
@@ -189,7 +301,7 @@ function OrganizationSelector({
189
301
  // Determine content state
190
302
  const hasError = Boolean(error);
191
303
  const isEmpty = organizations.length === 0;
192
- const hasNoResults = filteredOrganizations.length === 0 && !isEmpty;
304
+ const hasNoResults = sortedOrganizations.length === 0 && !isEmpty;
193
305
 
194
306
  const triggerButton = (
195
307
  <button
@@ -248,6 +360,27 @@ function OrganizationSelector({
248
360
  return 0;
249
361
  }}
250
362
  >
363
+ {/* Sort Menu */}
364
+ {enableSorting && (
365
+ <div data-slot="organization-selector-sort" className="p-1 pb-0">
366
+ <Select
367
+ value={`${sort.key}:${sort.direction}`}
368
+ onValueChange={(value) => value && setSort(parseSortValue(value))}
369
+ >
370
+ <SelectTrigger size="sm">
371
+ <span>{getSortLabel(sort)}</span>
372
+ </SelectTrigger>
373
+ <SelectContent align="start">
374
+ {sortOptions.map((option) => (
375
+ <SelectItem key={option.value} value={option.value}>
376
+ {option.label}
377
+ </SelectItem>
378
+ ))}
379
+ </SelectContent>
380
+ </Select>
381
+ </div>
382
+ )}
383
+
251
384
  {/* Search Input */}
252
385
  <div data-slot="command-input-wrapper" className="p-1 pb-0">
253
386
  <div
@@ -286,7 +419,7 @@ function OrganizationSelector({
286
419
  <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm">
287
420
  {emptySearchText}
288
421
  </CommandPrimitive.Empty>
289
- {organizations.map((org) => (
422
+ {sortedOrganizations.map((org) => (
290
423
  <CommandPrimitive.Item
291
424
  key={org.id}
292
425
  data-slot="command-item"
@@ -358,10 +491,31 @@ function OrganizationSelector({
358
491
  align="start"
359
492
  className="isolate z-50"
360
493
  >
361
- <ComboboxPrimitive.Popup
362
- data-slot="organization-selector-content"
363
- 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)"
364
- >
494
+ <ComboboxPrimitive.Popup
495
+ data-slot="organization-selector-content"
496
+ 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-visible 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)"
497
+ >
498
+ {/* Sort Menu */}
499
+ {enableSorting && (
500
+ <div data-slot="organization-selector-sort" className="border-b border-border p-2">
501
+ <Select
502
+ value={`${sort.key}:${sort.direction}`}
503
+ onValueChange={(value) => value && setSort(parseSortValue(value))}
504
+ >
505
+ <SelectTrigger size="sm">
506
+ <span>{getSortLabel(sort)}</span>
507
+ </SelectTrigger>
508
+ <SelectContent align="start" portal={false} position="absolute">
509
+ {sortOptions.map((option) => (
510
+ <SelectItem key={option.value} value={option.value}>
511
+ {option.label}
512
+ </SelectItem>
513
+ ))}
514
+ </SelectContent>
515
+ </Select>
516
+ </div>
517
+ )}
518
+
365
519
  {/* Search Input */}
366
520
  <div className="border-b border-border p-2">
367
521
  <div className="bg-muted/50 flex items-center gap-2 rounded-md px-2.5">
@@ -390,7 +544,7 @@ function OrganizationSelector({
390
544
  ) : hasNoResults ? (
391
545
  <EmptyState text={emptySearchText} />
392
546
  ) : (
393
- filteredOrganizations.map((org) => (
547
+ sortedOrganizations.map((org) => (
394
548
  <ComboboxPrimitive.Item
395
549
  key={org.id}
396
550
  data-slot="organization-selector-item"