@trycompai/design-system 1.0.0 → 1.0.2

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.
Files changed (101) hide show
  1. package/package.json +6 -3
  2. package/src/components/atoms/badge.tsx +49 -0
  3. package/src/components/{ui → atoms}/button.tsx +6 -1
  4. package/src/components/{ui → atoms}/checkbox.tsx +3 -3
  5. package/src/components/{ui → atoms}/heading.tsx +6 -6
  6. package/src/components/atoms/index.ts +21 -0
  7. package/src/components/atoms/kbd.tsx +21 -0
  8. package/src/components/atoms/logo.tsx +52 -0
  9. package/src/components/{ui → atoms}/slider.tsx +4 -4
  10. package/src/components/{ui → atoms}/spinner.tsx +3 -3
  11. package/src/components/atoms/stack.tsx +97 -0
  12. package/src/components/{ui → atoms}/switch.tsx +1 -1
  13. package/src/components/{ui → atoms}/text.tsx +5 -1
  14. package/src/components/{ui → atoms}/textarea.tsx +8 -2
  15. package/src/components/{ui → atoms}/toggle.tsx +3 -6
  16. package/src/components/{ui → molecules}/accordion.tsx +3 -3
  17. package/src/components/molecules/ai-chat.tsx +217 -0
  18. package/src/components/{ui → molecules}/alert.tsx +5 -5
  19. package/src/components/{ui → molecules}/breadcrumb.tsx +9 -8
  20. package/src/components/{ui → molecules}/card.tsx +24 -5
  21. package/src/components/molecules/command-search.tsx +147 -0
  22. package/src/components/molecules/empty.tsx +82 -0
  23. package/src/components/{ui → molecules}/field.tsx +16 -37
  24. package/src/components/{ui → molecules}/hover-card.tsx +2 -8
  25. package/src/components/molecules/index.ts +29 -0
  26. package/src/components/{ui → molecules}/input-group.tsx +1 -1
  27. package/src/components/molecules/input-otp.tsx +70 -0
  28. package/src/components/{ui → molecules}/item.tsx +18 -36
  29. package/src/components/molecules/page-header.tsx +80 -0
  30. package/src/components/{ui → molecules}/pagination.tsx +14 -23
  31. package/src/components/{ui → molecules}/popover.tsx +4 -2
  32. package/src/components/molecules/radio-group.tsx +33 -0
  33. package/src/components/{ui → molecules}/scroll-area.tsx +8 -11
  34. package/src/components/{ui → molecules}/section.tsx +3 -3
  35. package/src/components/{ui → molecules}/select.tsx +22 -10
  36. package/src/components/molecules/settings.tsx +169 -0
  37. package/src/components/{ui → molecules}/table.tsx +16 -3
  38. package/src/components/molecules/tabs.tsx +70 -0
  39. package/src/components/molecules/theme-switcher.tsx +176 -0
  40. package/src/components/{ui → molecules}/toggle-group.tsx +1 -1
  41. package/src/components/organisms/alert-dialog.tsx +135 -0
  42. package/src/components/organisms/app-shell.tsx +822 -0
  43. package/src/components/{ui → organisms}/calendar.tsx +6 -7
  44. package/src/components/{ui → organisms}/carousel.tsx +9 -11
  45. package/src/components/{ui → organisms}/chart.tsx +9 -24
  46. package/src/components/{ui → organisms}/combobox.tsx +7 -7
  47. package/src/components/{ui → organisms}/command.tsx +3 -3
  48. package/src/components/{ui → organisms}/context-menu.tsx +23 -53
  49. package/src/components/{ui → organisms}/dialog.tsx +3 -3
  50. package/src/components/{ui → organisms}/dropdown-menu.tsx +8 -6
  51. package/src/components/organisms/index.ts +17 -0
  52. package/src/components/{ui → organisms}/menubar.tsx +3 -3
  53. package/src/components/organisms/navigation-menu.tsx +137 -0
  54. package/src/components/organisms/page-layout.tsx +95 -0
  55. package/src/components/{ui → organisms}/sheet.tsx +7 -7
  56. package/src/components/{ui → organisms}/sidebar.tsx +61 -86
  57. package/src/components/organisms/sonner.tsx +41 -0
  58. package/src/components/ui/index.ts +3 -61
  59. package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff +0 -0
  60. package/src/fonts/TWKLausanne/TWKLausanne-300-Italic.woff2 +0 -0
  61. package/src/fonts/TWKLausanne/TWKLausanne-300.woff +0 -0
  62. package/src/fonts/TWKLausanne/TWKLausanne-300.woff2 +0 -0
  63. package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff +0 -0
  64. package/src/fonts/TWKLausanne/TWKLausanne-350-Italic.woff2 +0 -0
  65. package/src/fonts/TWKLausanne/TWKLausanne-350.woff +0 -0
  66. package/src/fonts/TWKLausanne/TWKLausanne-350.woff2 +0 -0
  67. package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff +0 -0
  68. package/src/fonts/TWKLausanne/TWKLausanne-400-Italic.woff2 +0 -0
  69. package/src/fonts/TWKLausanne/TWKLausanne-400.woff +0 -0
  70. package/src/fonts/TWKLausanne/TWKLausanne-400.woff2 +0 -0
  71. package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff +0 -0
  72. package/src/fonts/TWKLausanne/TWKLausanne-700-Italic.woff2 +0 -0
  73. package/src/fonts/TWKLausanne/TWKLausanne-700.woff +0 -0
  74. package/src/fonts/TWKLausanne/TWKLausanne-700.woff2 +0 -0
  75. package/src/styles/globals.css +167 -23
  76. package/src/components/ui/alert-dialog.tsx +0 -161
  77. package/src/components/ui/badge.tsx +0 -48
  78. package/src/components/ui/empty.tsx +0 -94
  79. package/src/components/ui/input-otp.tsx +0 -84
  80. package/src/components/ui/kbd.tsx +0 -26
  81. package/src/components/ui/navigation-menu.tsx +0 -147
  82. package/src/components/ui/page-header.tsx +0 -51
  83. package/src/components/ui/page-layout.tsx +0 -65
  84. package/src/components/ui/radio-group.tsx +0 -37
  85. package/src/components/ui/sonner.tsx +0 -43
  86. package/src/components/ui/stack.tsx +0 -72
  87. package/src/components/ui/tabs.tsx +0 -69
  88. /package/src/components/{ui → atoms}/aspect-ratio.tsx +0 -0
  89. /package/src/components/{ui → atoms}/avatar.tsx +0 -0
  90. /package/src/components/{ui → atoms}/container.tsx +0 -0
  91. /package/src/components/{ui → atoms}/input.tsx +0 -0
  92. /package/src/components/{ui → atoms}/label.tsx +0 -0
  93. /package/src/components/{ui → atoms}/progress.tsx +0 -0
  94. /package/src/components/{ui → atoms}/separator.tsx +0 -0
  95. /package/src/components/{ui → atoms}/skeleton.tsx +0 -0
  96. /package/src/components/{ui → molecules}/button-group.tsx +0 -0
  97. /package/src/components/{ui → molecules}/collapsible.tsx +0 -0
  98. /package/src/components/{ui → molecules}/grid.tsx +0 -0
  99. /package/src/components/{ui → molecules}/resizable.tsx +0 -0
  100. /package/src/components/{ui → molecules}/tooltip.tsx +0 -0
  101. /package/src/components/{ui → organisms}/drawer.tsx +0 -0
@@ -0,0 +1,217 @@
1
+ 'use client';
2
+
3
+ import { cva, type VariantProps } from 'class-variance-authority';
4
+ import { Close, MagicWand, Send } from '@carbon/icons-react';
5
+ import * as React from 'react';
6
+
7
+ const aiChatTriggerVariants = cva(
8
+ 'fixed bottom-6 right-6 z-50 flex items-center justify-center rounded-full transition-all duration-200 cursor-pointer',
9
+ {
10
+ variants: {
11
+ size: {
12
+ default: 'size-14',
13
+ sm: 'size-12',
14
+ lg: 'size-16',
15
+ },
16
+ variant: {
17
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95',
18
+ secondary: 'bg-foreground text-background hover:bg-foreground/90 active:scale-95',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ size: 'default',
23
+ variant: 'default',
24
+ },
25
+ },
26
+ );
27
+
28
+ const aiChatPanelVariants = cva(
29
+ 'fixed bottom-24 right-6 z-50 flex flex-col bg-background border border-border/40 rounded-2xl overflow-hidden transition-all duration-200 origin-bottom-right',
30
+ {
31
+ variants: {
32
+ size: {
33
+ default: 'w-96 h-[500px]',
34
+ sm: 'w-80 h-[400px]',
35
+ lg: 'w-[450px] h-[600px]',
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ size: 'default',
40
+ },
41
+ },
42
+ );
43
+
44
+ interface AIChatProps extends VariantProps<typeof aiChatTriggerVariants> {
45
+ /** Whether the chat panel is open */
46
+ open?: boolean;
47
+ /** Default open state (uncontrolled) */
48
+ defaultOpen?: boolean;
49
+ /** Callback when open state changes */
50
+ onOpenChange?: (open: boolean) => void;
51
+ /** Custom trigger icon */
52
+ triggerIcon?: React.ReactNode;
53
+ /** Panel size */
54
+ panelSize?: 'sm' | 'default' | 'lg';
55
+ /** Content to render inside the chat panel */
56
+ children?: React.ReactNode;
57
+ }
58
+
59
+ function AIChat({
60
+ open: openProp,
61
+ defaultOpen = false,
62
+ onOpenChange,
63
+ triggerIcon,
64
+ size = 'default',
65
+ variant = 'default',
66
+ panelSize = 'default',
67
+ children,
68
+ }: AIChatProps) {
69
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
70
+ const isOpen = openProp ?? internalOpen;
71
+
72
+ const handleToggle = () => {
73
+ const newValue = !isOpen;
74
+ if (openProp === undefined) {
75
+ setInternalOpen(newValue);
76
+ }
77
+ onOpenChange?.(newValue);
78
+ };
79
+
80
+ return (
81
+ <>
82
+ {/* Chat Panel */}
83
+ <div
84
+ data-slot="ai-chat-panel"
85
+ className={`${aiChatPanelVariants({ size: panelSize })} ${
86
+ isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0 pointer-events-none'
87
+ }`}
88
+ style={{
89
+ boxShadow: '0 8px 32px -4px rgb(0 0 0 / 0.12), 0 4px 16px -2px rgb(0 0 0 / 0.08)',
90
+ }}
91
+ >
92
+ {children || <AIChatDefaultContent onClose={handleToggle} />}
93
+ </div>
94
+
95
+ {/* Floating Trigger Button */}
96
+ <button
97
+ type="button"
98
+ data-slot="ai-chat-trigger"
99
+ onClick={handleToggle}
100
+ className={aiChatTriggerVariants({ size, variant })}
101
+ style={{
102
+ boxShadow: '0 4px 16px -2px rgb(0 0 0 / 0.15), 0 2px 8px -2px rgb(0 0 0 / 0.1)',
103
+ }}
104
+ aria-label={isOpen ? 'Close chat' : 'Open chat'}
105
+ >
106
+ <span
107
+ className={`absolute transition-all duration-200 ${
108
+ isOpen ? 'scale-0 opacity-0 rotate-90' : 'scale-100 opacity-100 rotate-0'
109
+ }`}
110
+ >
111
+ {triggerIcon || <MagicWand className="size-6" />}
112
+ </span>
113
+ <span
114
+ className={`absolute transition-all duration-200 ${
115
+ isOpen ? 'scale-100 opacity-100 rotate-0' : 'scale-0 opacity-0 -rotate-90'
116
+ }`}
117
+ >
118
+ <Close className="size-6" />
119
+ </span>
120
+ </button>
121
+ </>
122
+ );
123
+ }
124
+
125
+ // Default content when no children provided
126
+ function AIChatDefaultContent({ onClose }: { onClose: () => void }) {
127
+ return (
128
+ <>
129
+ {/* Header */}
130
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border/40">
131
+ <div className="flex items-center gap-2">
132
+ <span className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary">
133
+ <MagicWand className="size-4" />
134
+ </span>
135
+ <div>
136
+ <div className="font-semibold text-sm">AI Assistant</div>
137
+ <div className="text-xs text-muted-foreground">Ask me anything</div>
138
+ </div>
139
+ </div>
140
+ <button
141
+ type="button"
142
+ onClick={onClose}
143
+ className="size-8 flex items-center justify-center rounded-md hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
144
+ >
145
+ <Close className="size-4" />
146
+ </button>
147
+ </div>
148
+
149
+ {/* Messages Area */}
150
+ <div className="flex-1 overflow-auto p-4">
151
+ <div className="flex flex-col gap-4">
152
+ {/* AI Welcome Message */}
153
+ <div className="flex gap-3">
154
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
155
+ <MagicWand className="size-3.5" />
156
+ </span>
157
+ <div className="flex-1 rounded-2xl rounded-tl-sm bg-muted/50 dark:bg-muted px-3 py-2 text-sm">
158
+ Hi! I'm your AI assistant. How can I help you today?
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Input Area */}
165
+ <div className="p-3 border-t border-border/40">
166
+ <div className="flex items-center gap-2 rounded-xl bg-muted/50 dark:bg-muted px-3 py-2">
167
+ <input
168
+ type="text"
169
+ placeholder="Ask a question..."
170
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
171
+ />
172
+ <button
173
+ type="button"
174
+ className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
175
+ >
176
+ <Send className="size-4" />
177
+ </button>
178
+ </div>
179
+ <div className="mt-2 text-center text-xs text-muted-foreground">
180
+ Powered by AI
181
+ </div>
182
+ </div>
183
+ </>
184
+ );
185
+ }
186
+
187
+ // Compound components for custom content
188
+ function AIChatHeader({ children, ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
189
+ return (
190
+ <div
191
+ data-slot="ai-chat-header"
192
+ className="flex items-center justify-between px-4 py-3 border-b border-border/40"
193
+ {...props}
194
+ >
195
+ {children}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function AIChatBody({ children, ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
201
+ return (
202
+ <div data-slot="ai-chat-body" className="flex-1 overflow-auto p-4" {...props}>
203
+ {children}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ function AIChatFooter({ children, ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
209
+ return (
210
+ <div data-slot="ai-chat-footer" className="p-3 border-t border-border/40" {...props}>
211
+ {children}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ export { AIChat, AIChatHeader, AIChatBody, AIChatFooter, aiChatTriggerVariants, aiChatPanelVariants };
217
+ export type { AIChatProps };
@@ -1,5 +1,5 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react';
2
+ import { CheckmarkFilled, Information, Misuse, Warning } from '@carbon/icons-react';
3
3
  import * as React from 'react';
4
4
 
5
5
  const alertVariants = cva(
@@ -25,10 +25,10 @@ const alertVariants = cva(
25
25
 
26
26
  const variantIcons = {
27
27
  default: null,
28
- info: Info,
29
- success: CheckCircle2,
30
- warning: AlertTriangle,
31
- destructive: AlertCircle,
28
+ info: Information,
29
+ success: CheckmarkFilled,
30
+ warning: Warning,
31
+ destructive: Misuse,
32
32
  } as const;
33
33
 
34
34
  type AlertVariant = NonNullable<VariantProps<typeof alertVariants>['variant']>;
@@ -2,13 +2,13 @@ import { mergeProps } from '@base-ui/react/merge-props';
2
2
  import { useRender } from '@base-ui/react/use-render';
3
3
  import * as React from 'react';
4
4
 
5
- import { ArrowRightIcon, ChevronRightIcon, MoreHorizontalIcon, SlashIcon } from 'lucide-react';
5
+ import { ArrowRight, ChevronRight, OverflowMenuHorizontal } from '@carbon/icons-react';
6
6
  import {
7
7
  DropdownMenu,
8
8
  DropdownMenuContent,
9
9
  DropdownMenuItem,
10
10
  DropdownMenuTrigger,
11
- } from './dropdown-menu';
11
+ } from '../organisms/dropdown-menu';
12
12
 
13
13
  interface BreadcrumbItemData {
14
14
  /** The text label for the breadcrumb item */
@@ -26,9 +26,10 @@ interface BreadcrumbItemData {
26
26
  type BreadcrumbSeparatorType = 'chevron' | 'slash' | 'arrow';
27
27
 
28
28
  const separatorIcons: Record<BreadcrumbSeparatorType, React.ReactNode> = {
29
- chevron: <ChevronRightIcon />,
30
- slash: <SlashIcon />,
31
- arrow: <ArrowRightIcon />,
29
+ chevron: <ChevronRight />,
30
+ // Carbon doesn't ship a slash glyph icon; render it as text.
31
+ slash: <span className="text-xs leading-none">/</span>,
32
+ arrow: <ArrowRight />,
32
33
  };
33
34
 
34
35
  interface BreadcrumbProps extends Omit<React.ComponentProps<'nav'>, 'children' | 'className'> {
@@ -201,7 +202,7 @@ function BreadcrumbSeparator({
201
202
  className="[&>svg]:size-3.5"
202
203
  {...props}
203
204
  >
204
- {children ?? <ChevronRightIcon />}
205
+ {children ?? <ChevronRight />}
205
206
  </li>
206
207
  );
207
208
  }
@@ -215,7 +216,7 @@ function BreadcrumbEllipsis({ ...props }: Omit<React.ComponentProps<'span'>, 'cl
215
216
  className="size-5 [&>svg]:size-4 flex items-center justify-center"
216
217
  {...props}
217
218
  >
218
- <MoreHorizontalIcon />
219
+ <OverflowMenuHorizontal />
219
220
  <span className="sr-only">More</span>
220
221
  </span>
221
222
  );
@@ -229,7 +230,7 @@ function BreadcrumbEllipsisMenu({ collapsedItems }: { collapsedItems?: Breadcrum
229
230
  return (
230
231
  <DropdownMenu>
231
232
  <DropdownMenuTrigger variant="ellipsis" aria-label="Show hidden breadcrumb items">
232
- <MoreHorizontalIcon />
233
+ <OverflowMenuHorizontal />
233
234
  </DropdownMenuTrigger>
234
235
  <DropdownMenuContent align="start">
235
236
  {collapsedItems.map((item, index) => (
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import * as React from 'react';
3
3
 
4
4
  const cardVariants = cva(
5
- 'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
5
+ 'bg-card text-card-foreground border border-border/40 shadow-[0_1px_3px_0_rgb(0_0_0/0.06)] overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
6
6
  {
7
7
  variants: {
8
8
  width: {
@@ -25,14 +25,18 @@ const cardVariants = cva(
25
25
  full: 'max-w-full',
26
26
  },
27
27
  spacing: {
28
- default: '',
29
- tight: 'gap-3',
30
- relaxed: 'gap-6',
28
+ default: 'gap-4 data-[size=sm]:gap-3',
29
+ tight: 'gap-3 data-[size=sm]:gap-2.5',
30
+ relaxed: 'gap-6 data-[size=sm]:gap-4',
31
+ },
32
+ disabled: {
33
+ true: 'opacity-60 pointer-events-none select-none',
31
34
  },
32
35
  },
33
36
  defaultVariants: {
34
37
  width: 'auto',
35
38
  spacing: 'default',
39
+ disabled: undefined,
36
40
  },
37
41
  },
38
42
  );
@@ -56,6 +60,8 @@ function Card({
56
60
  size = 'default',
57
61
  width,
58
62
  maxWidth,
63
+ spacing,
64
+ disabled,
59
65
  title,
60
66
  description,
61
67
  headerAction,
@@ -68,6 +74,13 @@ function Card({
68
74
  // Check if children contain compound components (have data-slot)
69
75
  const hasCompoundChildren = React.Children.toArray(children).some((child) => {
70
76
  if (React.isValidElement(child)) {
77
+ // Prefer checking component identity. `data-slot` is applied inside the component render,
78
+ // so it won't exist on `child.props` unless manually passed in.
79
+ if (child.type === CardHeader || child.type === CardContent || child.type === CardFooter) {
80
+ return true;
81
+ }
82
+
83
+ // Fallback for direct DOM usage.
71
84
  const props = child.props as Record<string, unknown>;
72
85
  return (
73
86
  props['data-slot'] === 'card-header' ||
@@ -79,7 +92,13 @@ function Card({
79
92
  });
80
93
 
81
94
  return (
82
- <div data-slot="card" data-size={size} className={cardVariants({ width, maxWidth })} {...props}>
95
+ <div
96
+ data-slot="card"
97
+ data-size={size}
98
+ data-disabled={disabled ? '' : undefined}
99
+ className={cardVariants({ width, maxWidth, spacing, disabled: disabled ? true : undefined })}
100
+ {...props}
101
+ >
83
102
  {hasHeader && (
84
103
  <CardHeader>
85
104
  {title && <CardTitle>{title}</CardTitle>}
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Search } from '@carbon/icons-react';
5
+ import {
6
+ Command,
7
+ CommandDialog,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ CommandShortcut,
14
+ } from '../organisms/command';
15
+ import { Kbd } from '../atoms/kbd';
16
+
17
+ // ============ TYPES ============
18
+
19
+ export interface CommandSearchItem {
20
+ id: string;
21
+ label: string;
22
+ icon?: React.ReactNode;
23
+ shortcut?: string;
24
+ onSelect?: () => void;
25
+ keywords?: string[];
26
+ }
27
+
28
+ export interface CommandSearchGroup {
29
+ id: string;
30
+ label: string;
31
+ items: CommandSearchItem[];
32
+ }
33
+
34
+ export interface CommandSearchProps {
35
+ /** Placeholder text for the search input */
36
+ placeholder?: string;
37
+ /** Text shown when no results are found */
38
+ emptyText?: string;
39
+ /** Groups of searchable items */
40
+ groups?: CommandSearchGroup[];
41
+ /** Flat list of items (alternative to groups) */
42
+ items?: CommandSearchItem[];
43
+ /** Callback when an item is selected */
44
+ onSelect?: (item: CommandSearchItem) => void;
45
+ /** Controlled open state */
46
+ open?: boolean;
47
+ /** Callback when open state changes */
48
+ onOpenChange?: (open: boolean) => void;
49
+ /** Whether to show the trigger input */
50
+ showTrigger?: boolean;
51
+ /** Width of the trigger input */
52
+ triggerWidth?: 'sm' | 'md' | 'lg' | 'full';
53
+ }
54
+
55
+ // ============ COMPONENT ============
56
+
57
+ function CommandSearch({
58
+ placeholder = 'Search...',
59
+ emptyText = 'No results found.',
60
+ groups = [],
61
+ items = [],
62
+ onSelect,
63
+ open: openProp,
64
+ onOpenChange,
65
+ showTrigger = true,
66
+ triggerWidth = 'md',
67
+ }: CommandSearchProps) {
68
+ const [_open, _setOpen] = React.useState(false);
69
+ const open = openProp ?? _open;
70
+ const setOpen = onOpenChange ?? _setOpen;
71
+
72
+ // Listen for Cmd+K / Ctrl+K
73
+ React.useEffect(() => {
74
+ const handleKeyDown = (e: KeyboardEvent) => {
75
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
76
+ e.preventDefault();
77
+ setOpen(!open);
78
+ }
79
+ };
80
+
81
+ document.addEventListener('keydown', handleKeyDown);
82
+ return () => document.removeEventListener('keydown', handleKeyDown);
83
+ }, [open, setOpen]);
84
+
85
+ const handleSelect = (item: CommandSearchItem) => {
86
+ setOpen(false);
87
+ item.onSelect?.();
88
+ onSelect?.(item);
89
+ };
90
+
91
+ const widthClasses = {
92
+ sm: 'w-48 md:w-56',
93
+ md: 'w-56 md:w-72',
94
+ lg: 'w-72 md:w-96',
95
+ full: 'w-full max-w-md',
96
+ };
97
+
98
+ // Combine flat items into a default group if provided
99
+ const allGroups = items.length > 0
100
+ ? [{ id: 'default', label: '', items }, ...groups]
101
+ : groups;
102
+
103
+ return (
104
+ <>
105
+ {showTrigger && (
106
+ <button
107
+ type="button"
108
+ onClick={() => setOpen(true)}
109
+ className={`${widthClasses[triggerWidth]} inline-flex items-center gap-2 rounded-lg border border-input/50 bg-background/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-background hover:border-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
110
+ >
111
+ <Search className="size-4" />
112
+ <span className="flex-1 text-left">{placeholder}</span>
113
+ <Kbd>⌘K</Kbd>
114
+ </button>
115
+ )}
116
+
117
+ <CommandDialog open={open} onOpenChange={setOpen}>
118
+ <Command>
119
+ <CommandInput placeholder={placeholder} />
120
+ <CommandList>
121
+ <CommandEmpty>{emptyText}</CommandEmpty>
122
+ {allGroups.map((group) => (
123
+ <CommandGroup key={group.id} heading={group.label || undefined}>
124
+ {group.items.map((item) => (
125
+ <CommandItem
126
+ key={item.id}
127
+ value={item.label}
128
+ keywords={item.keywords}
129
+ onSelect={() => handleSelect(item)}
130
+ >
131
+ {item.icon}
132
+ <span>{item.label}</span>
133
+ {item.shortcut && (
134
+ <CommandShortcut>{item.shortcut}</CommandShortcut>
135
+ )}
136
+ </CommandItem>
137
+ ))}
138
+ </CommandGroup>
139
+ ))}
140
+ </CommandList>
141
+ </Command>
142
+ </CommandDialog>
143
+ </>
144
+ );
145
+ }
146
+
147
+ export { CommandSearch };
@@ -0,0 +1,82 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+
3
+ function Empty({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
4
+ return (
5
+ <div
6
+ data-slot="empty"
7
+ className="gap-4 rounded-lg border-dashed p-12 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance"
8
+ {...props}
9
+ />
10
+ );
11
+ }
12
+
13
+ function EmptyHeader({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
14
+ return (
15
+ <div
16
+ data-slot="empty-header"
17
+ className="gap-2 flex max-w-sm flex-col items-center"
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ const emptyMediaVariants = cva(
24
+ 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
25
+ {
26
+ variants: {
27
+ variant: {
28
+ default: 'bg-transparent',
29
+ icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ variant: 'default',
34
+ },
35
+ },
36
+ );
37
+
38
+ function EmptyMedia({
39
+ variant = 'default',
40
+ ...props
41
+ }: Omit<React.ComponentProps<'div'>, 'className'> & VariantProps<typeof emptyMediaVariants>) {
42
+ return (
43
+ <div
44
+ data-slot="empty-icon"
45
+ data-variant={variant}
46
+ className={emptyMediaVariants({ variant })}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ function EmptyTitle({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
53
+ return (
54
+ <div
55
+ data-slot="empty-title"
56
+ className="text-lg font-medium tracking-tight"
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function EmptyDescription({ ...props }: Omit<React.ComponentProps<'p'>, 'className'>) {
63
+ return (
64
+ <div
65
+ data-slot="empty-description"
66
+ className="text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4"
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ function EmptyContent({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
73
+ return (
74
+ <div
75
+ data-slot="empty-content"
76
+ className="gap-4 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance"
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };