@object-ui/plugin-detail 3.3.0 → 3.3.1

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 (134) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +21 -1
  3. package/dist/AddressField-LgHnO2Lk.js +98 -0
  4. package/dist/AutoNumberField-xZCrU0eW.js +14 -0
  5. package/dist/{AvatarField-Xuieq0ZI.js → AvatarField-Dy2XGlPz.js} +16 -15
  6. package/dist/{BooleanField-DwfMKknK.js → BooleanField-C0Clfka5.js} +11 -10
  7. package/dist/CodeField-CHUa07B6.js +23 -0
  8. package/dist/ColorField-vxHqEhcS.js +38 -0
  9. package/dist/CurrencyField-DiWjYWDo.js +49 -0
  10. package/dist/DateField-DGaRPM4P.js +22 -0
  11. package/dist/DateTimeField-8QnpsI_h.js +30 -0
  12. package/dist/EmailField-CkVgMbpI.js +26 -0
  13. package/dist/FileField-5UPV7uek.js +149 -0
  14. package/dist/FormulaField-BUgt6-Pi.js +17 -0
  15. package/dist/GeolocationField-D9T_jgG6.js +118 -0
  16. package/dist/GridField-DE_HwiIN.js +49 -0
  17. package/dist/ImageField-Dswnqtzf.js +73 -0
  18. package/dist/LocationField-gjqbE6na.js +36 -0
  19. package/dist/LookupField-BcS3LRKc.js +901 -0
  20. package/dist/{MasterDetailField-B0HTmmD7.js → MasterDetailField-BF6_-X3A.js} +20 -19
  21. package/dist/NumberField-Dj2rYmrS.js +27 -0
  22. package/dist/ObjectField-BymIojwd.js +50 -0
  23. package/dist/{PasswordField-DVTimsc3.js → PasswordField-ED_Xgqz-.js} +8 -7
  24. package/dist/PercentField-D-JKOxKC.js +61 -0
  25. package/dist/PhoneField-DSCaGYq7.js +26 -0
  26. package/dist/QRCodeField-CtcOUapi.js +73 -0
  27. package/dist/{RatingField-rRi_P0N0.js → RatingField-BDnyQFWy.js} +10 -9
  28. package/dist/RichTextField-CH6LVZQA.js +33 -0
  29. package/dist/SelectField-DE4dpkMV.js +36 -0
  30. package/dist/{SignatureField-2CnhcWI0.js → SignatureField-B1wh3f5A.js} +18 -17
  31. package/dist/{SliderField-DEpMVXko.js → SliderField-zoTCKh9n.js} +2 -1
  32. package/dist/SummaryField-BeBVT6VN.js +22 -0
  33. package/dist/TextAreaField-rfUGrRxh.js +37 -0
  34. package/dist/TextField-C_yM7ATQ.js +30 -0
  35. package/dist/TimeField-BcQmBZi9.js +22 -0
  36. package/dist/UrlField-BakaF6NI.js +31 -0
  37. package/dist/UserField-zS7y3eKb.js +76 -0
  38. package/dist/VectorField-CTZ4myDM.js +34 -0
  39. package/dist/index.js +1912 -1728
  40. package/dist/index.umd.cjs +38 -47
  41. package/dist/packages/plugin-detail/src/DetailSection.d.ts.map +1 -1
  42. package/dist/packages/plugin-detail/src/DetailView.d.ts +24 -0
  43. package/dist/packages/plugin-detail/src/DetailView.d.ts.map +1 -1
  44. package/dist/packages/plugin-detail/src/RelatedList.d.ts +8 -0
  45. package/dist/packages/plugin-detail/src/RelatedList.d.ts.map +1 -1
  46. package/dist/packages/plugin-detail/src/useDetailTranslation.d.ts.map +1 -1
  47. package/dist/plugin-detail.css +1 -2
  48. package/dist/rolldown-runtime-DnwLefa7.js +23 -0
  49. package/dist/{src-C56Ly5uG.js → src-DyUKLvMN.js} +18271 -26636
  50. package/dist/{useFieldTranslation-CkxqyB82.js → useFieldTranslation-BRgjC1oq.js} +1 -1
  51. package/package.json +33 -11
  52. package/.turbo/turbo-build.log +0 -64
  53. package/dist/AddressField-CDLSeyNx.js +0 -93
  54. package/dist/AutoNumberField-CtE7suf5.js +0 -14
  55. package/dist/CodeField-CfwgRxx2.js +0 -22
  56. package/dist/ColorField-YKHA7dBD.js +0 -37
  57. package/dist/CurrencyField-tvS3fPAF.js +0 -51
  58. package/dist/DateField-BKqXpkOh.js +0 -21
  59. package/dist/DateTimeField-CR-nJCE7.js +0 -32
  60. package/dist/EmailField-CgvW1Qal.js +0 -28
  61. package/dist/FileField-BVAme2ML.js +0 -151
  62. package/dist/FormulaField-DamJ2VaG.js +0 -14
  63. package/dist/GeolocationField-C99z7ZBM.js +0 -113
  64. package/dist/GridField-C9JbpTx_.js +0 -51
  65. package/dist/ImageField-CDANtgVV.js +0 -75
  66. package/dist/LocationField-ZSyZ0O-h.js +0 -35
  67. package/dist/LookupField-B3hQJt95.js +0 -903
  68. package/dist/LookupField-D00z6gn_.js +0 -2
  69. package/dist/NumberField-DL2QAL7X.js +0 -26
  70. package/dist/ObjectField-JYvUnuRO.js +0 -52
  71. package/dist/PercentField-DjR6BSpw.js +0 -63
  72. package/dist/PhoneField-CX1JL-jp.js +0 -28
  73. package/dist/QRCodeField-CH_1pU6R.js +0 -72
  74. package/dist/RichTextField-CJqLWlrb.js +0 -32
  75. package/dist/SelectField-DGoDoRM_.js +0 -30
  76. package/dist/SelectField-XBVI50AD.js +0 -2
  77. package/dist/SummaryField-7ch9aqAu.js +0 -19
  78. package/dist/TextAreaField-Cmw1oXcw.js +0 -36
  79. package/dist/TextField-OTLa3p51.js +0 -29
  80. package/dist/TimeField-DKPoNWoR.js +0 -21
  81. package/dist/UrlField-CxbmzP9f.js +0 -33
  82. package/dist/UserField-ChvwUkMK.js +0 -78
  83. package/dist/VectorField-BVClL8Vw.js +0 -36
  84. package/src/ActivityTimeline.tsx +0 -184
  85. package/src/CommentAttachment.tsx +0 -194
  86. package/src/CommentInput.tsx +0 -81
  87. package/src/DetailSection.tsx +0 -340
  88. package/src/DetailTabs.tsx +0 -73
  89. package/src/DetailView.stories.tsx +0 -334
  90. package/src/DetailView.tsx +0 -823
  91. package/src/DiffView.tsx +0 -233
  92. package/src/FieldChangeItem.tsx +0 -46
  93. package/src/HeaderHighlight.tsx +0 -88
  94. package/src/InlineCreateRelated.tsx +0 -291
  95. package/src/MentionAutocomplete.tsx +0 -123
  96. package/src/PointInTimeRestore.tsx +0 -261
  97. package/src/ReactionPicker.tsx +0 -106
  98. package/src/RecordActivityTimeline.tsx +0 -433
  99. package/src/RecordChatterPanel.tsx +0 -209
  100. package/src/RecordComments.tsx +0 -217
  101. package/src/RecordNavigationEnhanced.tsx +0 -213
  102. package/src/RelatedList.tsx +0 -413
  103. package/src/RelationshipGraph.tsx +0 -286
  104. package/src/RichTextCommentInput.tsx +0 -350
  105. package/src/SectionGroup.tsx +0 -101
  106. package/src/SubscriptionToggle.tsx +0 -62
  107. package/src/ThreadedReplies.tsx +0 -163
  108. package/src/__tests__/ActivityTimeline.test.tsx +0 -119
  109. package/src/__tests__/ActivityTimelineFiltering.test.tsx +0 -143
  110. package/src/__tests__/CommentInput.test.tsx +0 -57
  111. package/src/__tests__/DetailSection.test.tsx +0 -490
  112. package/src/__tests__/DetailView.test.tsx +0 -694
  113. package/src/__tests__/FieldChangeItem.test.tsx +0 -119
  114. package/src/__tests__/HeaderHighlight.test.tsx +0 -213
  115. package/src/__tests__/MentionAutocomplete.test.tsx +0 -97
  116. package/src/__tests__/ReactionPicker.test.tsx +0 -113
  117. package/src/__tests__/RecordActivityTimeline.test.tsx +0 -395
  118. package/src/__tests__/RecordChatterPanel.test.tsx +0 -265
  119. package/src/__tests__/RecordComments.test.tsx +0 -96
  120. package/src/__tests__/RecordCommentsPinSearch.test.tsx +0 -133
  121. package/src/__tests__/RelatedList.test.tsx +0 -160
  122. package/src/__tests__/SectionGroup.test.tsx +0 -101
  123. package/src/__tests__/SubscriptionToggle.test.tsx +0 -84
  124. package/src/__tests__/ThreadedReplies.test.tsx +0 -212
  125. package/src/__tests__/autoLayout.test.ts +0 -228
  126. package/src/__tests__/phase12-features.test.tsx +0 -583
  127. package/src/__tests__/roadmap-features.test.tsx +0 -478
  128. package/src/autoLayout.ts +0 -128
  129. package/src/index.tsx +0 -149
  130. package/src/useDetailTranslation.ts +0 -183
  131. package/tsconfig.json +0 -18
  132. package/vite.config.ts +0 -57
  133. package/vitest.config.ts +0 -13
  134. package/vitest.setup.ts +0 -1
@@ -1,184 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
11
- import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft, Filter } from 'lucide-react';
12
- import type { ActivityEntry } from '@object-ui/types';
13
-
14
- export type ActivityFilterType = ActivityEntry['type'] | 'all';
15
-
16
- export interface ActivityTimelineProps {
17
- activities: ActivityEntry[];
18
- /** Show filter controls for activity types */
19
- filterable?: boolean;
20
- /** Default filter (defaults to 'all') */
21
- defaultFilter?: ActivityFilterType;
22
- className?: string;
23
- }
24
-
25
- const ACTIVITY_ICONS: Record<ActivityEntry['type'], React.ElementType> = {
26
- field_change: Edit,
27
- create: PlusCircle,
28
- delete: Trash2,
29
- comment: MessageSquare,
30
- status_change: ArrowRightLeft,
31
- };
32
-
33
- const ACTIVITY_COLORS: Record<ActivityEntry['type'], string> = {
34
- field_change: 'bg-blue-100 text-blue-600',
35
- create: 'bg-green-100 text-green-600',
36
- delete: 'bg-red-100 text-red-600',
37
- comment: 'bg-purple-100 text-purple-600',
38
- status_change: 'bg-amber-100 text-amber-600',
39
- };
40
-
41
- function formatTimestamp(timestamp: string): string {
42
- try {
43
- const date = new Date(timestamp);
44
- const now = new Date();
45
- const diffMs = now.getTime() - date.getTime();
46
- const diffMins = Math.floor(diffMs / 60000);
47
-
48
- if (diffMins < 1) return 'just now';
49
- if (diffMins < 60) return `${diffMins}m ago`;
50
- const diffHours = Math.floor(diffMins / 60);
51
- if (diffHours < 24) return `${diffHours}h ago`;
52
- const diffDays = Math.floor(diffHours / 24);
53
- if (diffDays < 7) return `${diffDays}d ago`;
54
- return date.toLocaleDateString();
55
- } catch {
56
- return timestamp;
57
- }
58
- }
59
-
60
- function formatFieldChange(entry: ActivityEntry): string {
61
- if (entry.description) return entry.description;
62
-
63
- if (entry.type === 'field_change' && entry.field) {
64
- const fieldLabel = entry.field.charAt(0).toUpperCase() + entry.field.slice(1).replace(/_/g, ' ');
65
- const oldVal = entry.oldValue != null ? String(entry.oldValue) : '(empty)';
66
- const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)';
67
- return `Changed ${fieldLabel} from "${oldVal}" to "${newVal}"`;
68
- }
69
-
70
- if (entry.type === 'create') return 'Created this record';
71
- if (entry.type === 'delete') return 'Deleted this record';
72
- if (entry.type === 'status_change' && entry.field) {
73
- const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)';
74
- return `Changed status to "${newVal}"`;
75
- }
76
-
77
- return 'Updated record';
78
- }
79
-
80
- const FILTER_LABELS: Record<ActivityFilterType, string> = {
81
- all: 'All',
82
- field_change: 'Field Changes',
83
- create: 'Creates',
84
- delete: 'Deletes',
85
- comment: 'Comments',
86
- status_change: 'Status Changes',
87
- };
88
-
89
- export const ActivityTimeline: React.FC<ActivityTimelineProps> = ({
90
- activities,
91
- filterable = false,
92
- defaultFilter = 'all',
93
- className,
94
- }) => {
95
- const [activeFilter, setActiveFilter] = React.useState<ActivityFilterType>(defaultFilter);
96
-
97
- const filteredActivities = React.useMemo(() => {
98
- if (activeFilter === 'all') return activities;
99
- return activities.filter(a => a.type === activeFilter);
100
- }, [activities, activeFilter]);
101
-
102
- return (
103
- <Card className={cn('', className)}>
104
- <CardHeader>
105
- <CardTitle className="flex items-center gap-2 text-base">
106
- <Activity className="h-4 w-4" />
107
- Activity
108
- <span className="text-sm font-normal text-muted-foreground">
109
- ({filteredActivities.length})
110
- </span>
111
- </CardTitle>
112
- </CardHeader>
113
- <CardContent>
114
- {/* Filter controls */}
115
- {filterable && (
116
- <div className="flex flex-wrap gap-1.5 mb-4" role="group" aria-label="Activity type filter">
117
- {(Object.keys(FILTER_LABELS) as ActivityFilterType[]).map(type => (
118
- <button
119
- key={type}
120
- type="button"
121
- className={cn(
122
- 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
123
- activeFilter === type
124
- ? 'bg-primary text-primary-foreground'
125
- : 'bg-muted text-muted-foreground hover:bg-muted/80',
126
- )}
127
- onClick={() => setActiveFilter(type)}
128
- aria-pressed={activeFilter === type}
129
- >
130
- {type !== 'all' && React.createElement(ACTIVITY_ICONS[type] || Edit, { className: 'h-3 w-3' })}
131
- {FILTER_LABELS[type]}
132
- </button>
133
- ))}
134
- </div>
135
- )}
136
-
137
- {filteredActivities.length === 0 ? (
138
- <p className="text-sm text-muted-foreground text-center py-4">
139
- No activity recorded
140
- </p>
141
- ) : (
142
- <div className="relative">
143
- {/* Timeline line */}
144
- <div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
145
-
146
- <div className="space-y-4">
147
- {filteredActivities.map((entry) => {
148
- const Icon = ACTIVITY_ICONS[entry.type] || Edit;
149
- const colorClass = ACTIVITY_COLORS[entry.type] || 'bg-gray-100 text-gray-600';
150
-
151
- return (
152
- <div key={entry.id} className="flex gap-3 relative">
153
- {/* Icon */}
154
- <div
155
- className={cn(
156
- 'shrink-0 h-8 w-8 rounded-full flex items-center justify-center z-10',
157
- colorClass,
158
- )}
159
- >
160
- <Icon className="h-3.5 w-3.5" />
161
- </div>
162
- {/* Content */}
163
- <div className="flex-1 min-w-0 pt-1">
164
- <p className="text-sm">
165
- <span className="font-medium">{entry.user}</span>
166
- {' '}
167
- <span className="text-muted-foreground">
168
- {formatFieldChange(entry)}
169
- </span>
170
- </p>
171
- <p className="text-xs text-muted-foreground mt-0.5">
172
- {formatTimestamp(entry.timestamp)}
173
- </p>
174
- </div>
175
- </div>
176
- );
177
- })}
178
- </div>
179
- </div>
180
- )}
181
- </CardContent>
182
- </Card>
183
- );
184
- };
@@ -1,194 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Button } from '@object-ui/components';
11
- import { Paperclip, X, FileText, Image, FileArchive, File, Upload } from 'lucide-react';
12
- import { useDetailTranslation } from './useDetailTranslation';
13
-
14
- export interface Attachment {
15
- id: string;
16
- name: string;
17
- size: number;
18
- type: string;
19
- url?: string;
20
- thumbnailUrl?: string;
21
- }
22
-
23
- export interface CommentAttachmentProps {
24
- attachments: Attachment[];
25
- onUpload?: (files: FileList) => void | Promise<void>;
26
- onRemove?: (attachmentId: string) => void;
27
- className?: string;
28
- readOnly?: boolean;
29
- }
30
-
31
- function formatFileSize(bytes: number): string {
32
- if (bytes < 1024) return `${bytes} B`;
33
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
34
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
- }
36
-
37
- function isImageType(type: string): boolean {
38
- return type.startsWith('image/');
39
- }
40
-
41
- function getFileIcon(type: string): React.ElementType {
42
- if (isImageType(type)) return Image;
43
- if (type.includes('pdf') || type.includes('document') || type.includes('text'))
44
- return FileText;
45
- if (type.includes('zip') || type.includes('archive') || type.includes('compressed'))
46
- return FileArchive;
47
- return File;
48
- }
49
-
50
- export const CommentAttachment: React.FC<CommentAttachmentProps> = ({
51
- attachments,
52
- onUpload,
53
- onRemove,
54
- className,
55
- readOnly = false,
56
- }) => {
57
- const { t } = useDetailTranslation();
58
- const [isDragOver, setIsDragOver] = React.useState(false);
59
- const fileInputRef = React.useRef<HTMLInputElement>(null);
60
-
61
- const handleDragOver = React.useCallback((e: React.DragEvent) => {
62
- e.preventDefault();
63
- e.stopPropagation();
64
- setIsDragOver(true);
65
- }, []);
66
-
67
- const handleDragLeave = React.useCallback((e: React.DragEvent) => {
68
- e.preventDefault();
69
- e.stopPropagation();
70
- setIsDragOver(false);
71
- }, []);
72
-
73
- const handleDrop = React.useCallback(
74
- (e: React.DragEvent) => {
75
- e.preventDefault();
76
- e.stopPropagation();
77
- setIsDragOver(false);
78
- if (onUpload && e.dataTransfer.files.length > 0) {
79
- onUpload(e.dataTransfer.files);
80
- }
81
- },
82
- [onUpload],
83
- );
84
-
85
- const handleFileSelect = React.useCallback(
86
- (e: React.ChangeEvent<HTMLInputElement>) => {
87
- if (onUpload && e.target.files && e.target.files.length > 0) {
88
- onUpload(e.target.files);
89
- // Reset so the same file can be selected again
90
- e.target.value = '';
91
- }
92
- },
93
- [onUpload],
94
- );
95
-
96
- return (
97
- <div className={cn('space-y-2', className)}>
98
- {/* Drop zone */}
99
- {onUpload && !readOnly && (
100
- <div
101
- className={cn(
102
- 'border-2 border-dashed rounded-md px-4 py-3 text-center transition-colors cursor-pointer',
103
- isDragOver
104
- ? 'border-primary bg-primary/5'
105
- : 'border-muted-foreground/25 hover:border-muted-foreground/40',
106
- )}
107
- onDragOver={handleDragOver}
108
- onDragLeave={handleDragLeave}
109
- onDrop={handleDrop}
110
- onClick={() => fileInputRef.current?.click()}
111
- role="button"
112
- tabIndex={0}
113
- onKeyDown={(e) => {
114
- if (e.key === 'Enter' || e.key === ' ') {
115
- e.preventDefault();
116
- fileInputRef.current?.click();
117
- }
118
- }}
119
- >
120
- <Upload className="h-5 w-5 mx-auto text-muted-foreground mb-1" />
121
- <p className="text-xs text-muted-foreground">
122
- {t('detail.dropFilesToUpload')}
123
- </p>
124
- <input
125
- ref={fileInputRef}
126
- type="file"
127
- multiple
128
- className="hidden"
129
- onChange={handleFileSelect}
130
- />
131
- </div>
132
- )}
133
-
134
- {/* Attachment list */}
135
- {attachments.length > 0 && (
136
- <div className="space-y-1.5">
137
- <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
138
- <Paperclip className="h-3 w-3" />
139
- <span>
140
- {attachments.length !== 1 ? t('detail.attachmentCountPlural', { count: attachments.length }) : t('detail.attachmentCount', { count: attachments.length })}
141
- </span>
142
- </div>
143
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
144
- {attachments.map((attachment) => {
145
- const isImage = isImageType(attachment.type);
146
- const Icon = getFileIcon(attachment.type);
147
-
148
- return (
149
- <div
150
- key={attachment.id}
151
- className="flex items-center gap-2 rounded-md border px-2.5 py-2 bg-muted/30 group"
152
- >
153
- {/* Thumbnail or icon */}
154
- {isImage && (attachment.thumbnailUrl || attachment.url) ? (
155
- <img
156
- src={attachment.thumbnailUrl || attachment.url}
157
- alt={attachment.name}
158
- className="h-10 w-10 rounded object-cover shrink-0"
159
- />
160
- ) : (
161
- <div className="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0">
162
- <Icon className="h-5 w-5 text-muted-foreground" />
163
- </div>
164
- )}
165
-
166
- {/* File info */}
167
- <div className="flex-1 min-w-0">
168
- <p className="text-xs font-medium truncate">{attachment.name}</p>
169
- <p className="text-[10px] text-muted-foreground">
170
- {formatFileSize(attachment.size)}
171
- </p>
172
- </div>
173
-
174
- {/* Remove button */}
175
- {onRemove && !readOnly && (
176
- <Button
177
- variant="ghost"
178
- size="icon"
179
- className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
180
- onClick={() => onRemove(attachment.id)}
181
- title={t('detail.removeAttachment')}
182
- >
183
- <X className="h-3.5 w-3.5" />
184
- </Button>
185
- )}
186
- </div>
187
- );
188
- })}
189
- </div>
190
- </div>
191
- )}
192
- </div>
193
- );
194
- };
@@ -1,81 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Button } from '@object-ui/components';
11
- import { Send } from 'lucide-react';
12
-
13
- export interface CommentInputProps {
14
- /** Called when a comment is submitted */
15
- onSubmit: (text: string) => void | Promise<void>;
16
- /** Placeholder text */
17
- placeholder?: string;
18
- /** Whether the input is disabled */
19
- disabled?: boolean;
20
- className?: string;
21
- }
22
-
23
- /**
24
- * CommentInput — Simple comment input component.
25
- * Renders a "Leave a comment" textarea with submit button.
26
- * Supports Ctrl+Enter to submit.
27
- */
28
- export const CommentInput: React.FC<CommentInputProps> = ({
29
- onSubmit,
30
- placeholder = 'Leave a comment…',
31
- disabled = false,
32
- className,
33
- }) => {
34
- const [text, setText] = React.useState('');
35
- const [isSubmitting, setIsSubmitting] = React.useState(false);
36
-
37
- const handleSubmit = React.useCallback(async () => {
38
- const value = text.trim();
39
- if (!value) return;
40
- setIsSubmitting(true);
41
- try {
42
- await onSubmit(value);
43
- setText('');
44
- } finally {
45
- setIsSubmitting(false);
46
- }
47
- }, [text, onSubmit]);
48
-
49
- const handleKeyDown = React.useCallback(
50
- (e: React.KeyboardEvent) => {
51
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
52
- e.preventDefault();
53
- handleSubmit();
54
- }
55
- },
56
- [handleSubmit],
57
- );
58
-
59
- return (
60
- <div className={cn('flex gap-2', className)}>
61
- <textarea
62
- className="flex-1 min-h-[60px] rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
63
- placeholder={placeholder}
64
- value={text}
65
- onChange={(e) => setText(e.target.value)}
66
- onKeyDown={handleKeyDown}
67
- disabled={disabled || isSubmitting}
68
- />
69
- <Button
70
- size="icon"
71
- variant="default"
72
- onClick={handleSubmit}
73
- disabled={!text.trim() || isSubmitting || disabled}
74
- className="shrink-0 self-end"
75
- aria-label="Submit comment"
76
- >
77
- <Send className="h-4 w-4" />
78
- </Button>
79
- </div>
80
- );
81
- };