@trycompai/design-system 1.0.34 → 1.0.36

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.34",
3
+ "version": "1.0.36",
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'>) {
@@ -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
  // ============================================================================
@@ -18,10 +25,20 @@ export interface Organization {
18
25
  icon?: React.ReactNode;
19
26
  /** Optional logo URL to display */
20
27
  logoUrl?: string;
28
+ /** Optional created at timestamp used for sorting */
29
+ createdAt?: string | number | Date;
21
30
  /** Optional brand color for the indicator dot (e.g., "#10b981" or "bg-green-500") */
22
31
  color?: string;
23
32
  }
24
33
 
34
+ type OrganizationSortKey = 'name' | 'createdAt';
35
+ type OrganizationSortDirection = 'asc' | 'desc';
36
+
37
+ type OrganizationSort = {
38
+ key: OrganizationSortKey;
39
+ direction: OrganizationSortDirection;
40
+ };
41
+
25
42
  export interface OrganizationSelectorProps {
26
43
  /** List of organizations to display */
27
44
  organizations: Organization[];
@@ -61,6 +78,12 @@ export interface OrganizationSelectorProps {
61
78
  createLabel?: string;
62
79
  /** Callback when create action is selected */
63
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;
64
87
  }
65
88
 
66
89
  // ============================================================================
@@ -139,6 +162,40 @@ function OrganizationItemContent({ org, maxWidth }: { org: Organization; maxWidt
139
162
  );
140
163
  }
141
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
+
142
199
  // ============================================================================
143
200
  // Main Component
144
201
  // ============================================================================
@@ -163,10 +220,16 @@ function OrganizationSelector({
163
220
  footer,
164
221
  createLabel = 'Create organization',
165
222
  onCreate,
223
+ closeOnSelect = false,
224
+ defaultSort = { key: 'name', direction: 'asc' },
225
+ enableSorting = true,
166
226
  }: OrganizationSelectorProps) {
167
227
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
168
228
  const [searchQuery, setSearchQuery] = React.useState('');
169
229
  const [open, setOpen] = React.useState(false);
230
+ const [sort, setSort] = React.useState<OrganizationSort>(defaultSort);
231
+ const [dropdownOpen, setDropdownOpen] = React.useState(false);
232
+ const [sortOpen, setSortOpen] = React.useState(false);
170
233
  const triggerRef = React.useRef<HTMLButtonElement>(null);
171
234
 
172
235
  const selectedValue = value ?? internalValue;
@@ -202,6 +265,24 @@ function OrganizationSelector({
202
265
  );
203
266
  }, [organizations, searchQuery]);
204
267
 
268
+ const sortedOrganizations = React.useMemo(() => {
269
+ const sorted = [...filteredOrganizations];
270
+ sorted.sort((a, b) => {
271
+ if (sort.key === 'name') {
272
+ const comparison = a.name.localeCompare(b.name);
273
+ return sort.direction === 'asc' ? comparison : -comparison;
274
+ }
275
+
276
+ const aTime = getCreatedAtValue(a.createdAt);
277
+ const bTime = getCreatedAtValue(b.createdAt);
278
+ if (aTime === null && bTime === null) return 0;
279
+ if (aTime === null) return 1;
280
+ if (bTime === null) return -1;
281
+ return sort.direction === 'asc' ? aTime - bTime : bTime - aTime;
282
+ });
283
+ return sorted;
284
+ }, [filteredOrganizations, sort]);
285
+
205
286
  const handleValueChange = React.useCallback(
206
287
  (newValue: string | null) => {
207
288
  const orgId = newValue ?? '';
@@ -210,9 +291,12 @@ function OrganizationSelector({
210
291
  }
211
292
  onValueChange?.(orgId);
212
293
  setSearchQuery(''); // Clear search on selection
213
- setOpen(false); // Close modal on selection
294
+ if (closeOnSelect) {
295
+ setOpen(false); // Close modal on selection
296
+ setDropdownOpen(false); // Close dropdown on selection
297
+ }
214
298
  },
215
- [value, onValueChange]
299
+ [value, onValueChange, closeOnSelect]
216
300
  );
217
301
 
218
302
  const triggerSizeClass = size === 'sm' ? 'h-7' : 'h-8';
@@ -220,7 +304,7 @@ function OrganizationSelector({
220
304
  // Determine content state
221
305
  const hasError = Boolean(error);
222
306
  const isEmpty = organizations.length === 0;
223
- const hasNoResults = filteredOrganizations.length === 0 && !isEmpty;
307
+ const hasNoResults = sortedOrganizations.length === 0 && !isEmpty;
224
308
 
225
309
  const triggerButton = (
226
310
  <button
@@ -279,8 +363,29 @@ function OrganizationSelector({
279
363
  return 0;
280
364
  }}
281
365
  >
366
+ {/* Sort Menu */}
367
+ {enableSorting && (
368
+ <div data-slot="organization-selector-sort" className="border-b border-border p-2">
369
+ <Select
370
+ value={`${sort.key}:${sort.direction}`}
371
+ onValueChange={(value) => value && setSort(parseSortValue(value))}
372
+ >
373
+ <SelectTrigger size="sm">
374
+ <span>{getSortLabel(sort)}</span>
375
+ </SelectTrigger>
376
+ <SelectContent align="start">
377
+ {sortOptions.map((option) => (
378
+ <SelectItem key={option.value} value={option.value}>
379
+ {option.label}
380
+ </SelectItem>
381
+ ))}
382
+ </SelectContent>
383
+ </Select>
384
+ </div>
385
+ )}
386
+
282
387
  {/* Search Input */}
283
- <div data-slot="command-input-wrapper" className="p-1 pb-0">
388
+ <div data-slot="command-input-wrapper" className="border-b border-border p-2">
284
389
  <div
285
390
  data-slot="input-group"
286
391
  role="group"
@@ -304,7 +409,7 @@ function OrganizationSelector({
304
409
  {/* Organization List */}
305
410
  <CommandPrimitive.List
306
411
  data-slot="command-list"
307
- className="no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto"
412
+ className="no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto pt-2"
308
413
  >
309
414
  {loading ? (
310
415
  <EmptyState icon={<LoadingSpinner />} text={loadingText} />
@@ -317,7 +422,7 @@ function OrganizationSelector({
317
422
  <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm">
318
423
  {emptySearchText}
319
424
  </CommandPrimitive.Empty>
320
- {organizations.map((org) => (
425
+ {sortedOrganizations.map((org) => (
321
426
  <CommandPrimitive.Item
322
427
  key={org.id}
323
428
  data-slot="command-item"
@@ -366,7 +471,15 @@ function OrganizationSelector({
366
471
 
367
472
  // Dropdown mode (default): inline combobox
368
473
  return (
369
- <ComboboxPrimitive.Root value={selectedValue} onValueChange={handleValueChange}>
474
+ <ComboboxPrimitive.Root
475
+ value={selectedValue}
476
+ onValueChange={handleValueChange}
477
+ open={dropdownOpen}
478
+ onOpenChange={(nextOpen) => {
479
+ if (sortOpen && !nextOpen) return;
480
+ setDropdownOpen(nextOpen);
481
+ }}
482
+ >
370
483
  <ComboboxPrimitive.Trigger
371
484
  ref={triggerRef}
372
485
  data-slot="organization-selector-trigger"
@@ -389,10 +502,32 @@ function OrganizationSelector({
389
502
  align="start"
390
503
  className="isolate z-50"
391
504
  >
392
- <ComboboxPrimitive.Popup
393
- data-slot="organization-selector-content"
394
- 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)"
395
- >
505
+ <ComboboxPrimitive.Popup
506
+ data-slot="organization-selector-content"
507
+ 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)"
508
+ >
509
+ {/* Sort Menu */}
510
+ {enableSorting && (
511
+ <div data-slot="organization-selector-sort" className="border-b border-border p-2">
512
+ <Select
513
+ value={`${sort.key}:${sort.direction}`}
514
+ onValueChange={(value) => value && setSort(parseSortValue(value))}
515
+ onOpenChange={setSortOpen}
516
+ >
517
+ <SelectTrigger size="sm">
518
+ <span>{getSortLabel(sort)}</span>
519
+ </SelectTrigger>
520
+ <SelectContent align="start">
521
+ {sortOptions.map((option) => (
522
+ <SelectItem key={option.value} value={option.value}>
523
+ {option.label}
524
+ </SelectItem>
525
+ ))}
526
+ </SelectContent>
527
+ </Select>
528
+ </div>
529
+ )}
530
+
396
531
  {/* Search Input */}
397
532
  <div className="border-b border-border p-2">
398
533
  <div className="bg-muted/50 flex items-center gap-2 rounded-md px-2.5">
@@ -410,7 +545,7 @@ function OrganizationSelector({
410
545
  {/* Organization List */}
411
546
  <ComboboxPrimitive.List
412
547
  data-slot="organization-selector-list"
413
- className="no-scrollbar max-h-64 scroll-py-1 overflow-y-auto p-1 overscroll-contain"
548
+ className="no-scrollbar max-h-64 scroll-py-1 overflow-y-auto px-1 pb-1 pt-2 overscroll-contain"
414
549
  >
415
550
  {loading ? (
416
551
  <EmptyState icon={<LoadingSpinner />} text={loadingText} />
@@ -421,7 +556,7 @@ function OrganizationSelector({
421
556
  ) : hasNoResults ? (
422
557
  <EmptyState text={emptySearchText} />
423
558
  ) : (
424
- filteredOrganizations.map((org) => (
559
+ sortedOrganizations.map((org) => (
425
560
  <ComboboxPrimitive.Item
426
561
  key={org.id}
427
562
  data-slot="organization-selector-item"