@vertesia/ui 0.79.1 → 0.80.0-dev-20251118
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 +6 -1
- package/src/core/components/SelectBox.tsx +1 -1
- package/src/core/components/TagsInput.tsx +388 -0
- package/src/core/components/index.ts +2 -1
- package/src/core/components/shadcn/popover.tsx +2 -2
- package/src/core/hooks/CompositeState.tsx +156 -6
- package/src/env/index.ts +4 -3
- package/src/features/agent/PayloadBuilder.tsx +92 -65
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +1 -1
- package/src/features/agent/chat/ModernAgentOutput/Header.tsx +2 -2
- package/src/features/agent/chat/ModernAgentOutput/WorkstreamTabs.tsx +3 -3
- package/src/features/store/collections/EditCollectionView.tsx +31 -10
- package/src/features/store/collections/SharedPropsEditor.tsx +61 -0
- package/src/features/store/collections/SyncMemberHeadsToggle.tsx +48 -0
- package/src/features/store/collections/index.ts +3 -1
- package/src/features/store/objects/DocumentSearchResults.tsx +11 -2
- package/src/features/store/objects/components/useDownloadObject.ts +7 -2
- package/src/features/user/UserInfo.tsx +2 -0
- package/src/session/UserSession.ts +1 -0
- package/src/session/UserSessionProvider.tsx +10 -2
- package/src/session/auth/composable.ts +71 -70
- package/src/shell/apps/AppProjectSelector.tsx +2 -2
- package/src/widgets/form/Form.tsx +1 -1
- package/src/widgets/form/ManagedObject.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertesia/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0-dev-20251118",
|
|
4
4
|
"description": "Vertesia UI components and and hooks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -168,5 +168,10 @@
|
|
|
168
168
|
"./lib/types/shell/index.d.ts"
|
|
169
169
|
]
|
|
170
170
|
}
|
|
171
|
+
},
|
|
172
|
+
"repository": {
|
|
173
|
+
"type": "git",
|
|
174
|
+
"url": "https://github.com/vertesia/composableai.git",
|
|
175
|
+
"directory": "packages/ui"
|
|
171
176
|
}
|
|
172
177
|
}
|
|
@@ -90,7 +90,7 @@ export function SelectBox<T>({ clearTitle, ClearIcon = X, showFilter, filterBy,
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
return (
|
|
93
|
-
<Listbox value={value
|
|
93
|
+
<Listbox value={value ?? undefined} onChange={onChange} by={by} disabled={disabled}>
|
|
94
94
|
{() => (
|
|
95
95
|
<div className={'overflow-y-visible ' + className}>
|
|
96
96
|
<div className="relative">
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Popover, PopoverContent, PopoverContext, PopoverTrigger } from './shadcn/popover';
|
|
5
|
+
|
|
6
|
+
interface TagsInputProps {
|
|
7
|
+
options: string[];
|
|
8
|
+
value: string[];
|
|
9
|
+
onChange: (selected: string[]) => void;
|
|
10
|
+
onOptionsChange?: (options: string[]) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
layout?: 'horizontal' | 'vertical';
|
|
15
|
+
creatable?: boolean;
|
|
16
|
+
createText?: string;
|
|
17
|
+
maxDropdownHeight?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function TagsInputContent({
|
|
21
|
+
options,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
onOptionsChange,
|
|
25
|
+
placeholder,
|
|
26
|
+
className,
|
|
27
|
+
disabled,
|
|
28
|
+
layout = 'horizontal',
|
|
29
|
+
creatable = false,
|
|
30
|
+
createText = 'Create "%value%"',
|
|
31
|
+
maxDropdownHeight = 200
|
|
32
|
+
}: TagsInputProps) {
|
|
33
|
+
const popoverContext = useContext(PopoverContext);
|
|
34
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
35
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
36
|
+
const [width, setWidth] = useState<number>(0);
|
|
37
|
+
const [pendingDeleteIndex, setPendingDeleteIndex] = useState<number | null>(null);
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const highlightedItemRef = useRef<HTMLElement | null>(null);
|
|
42
|
+
|
|
43
|
+
const isOpen = popoverContext?.open ?? false;
|
|
44
|
+
const setIsOpen = popoverContext?.setOpen ?? (() => { });
|
|
45
|
+
|
|
46
|
+
// Measure trigger width for popover
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const element = triggerRef.current;
|
|
49
|
+
if (!element) return;
|
|
50
|
+
|
|
51
|
+
const updateWidth = () => {
|
|
52
|
+
const contentWidth = element.getBoundingClientRect().width;
|
|
53
|
+
setWidth(contentWidth);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
57
|
+
updateWidth();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
updateWidth();
|
|
61
|
+
resizeObserver.observe(element);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
resizeObserver.disconnect();
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// Filter options based on search term and exclude already selected
|
|
69
|
+
const filteredOptions = options.filter(
|
|
70
|
+
option =>
|
|
71
|
+
!value.includes(option) &&
|
|
72
|
+
option.toLowerCase().includes(searchTerm.toLowerCase())
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Check if create option should be shown
|
|
76
|
+
const showCreateOption = creatable && searchTerm && !value.includes(searchTerm) && !options.includes(searchTerm);
|
|
77
|
+
|
|
78
|
+
// Total number of items (filtered options + create option if shown)
|
|
79
|
+
const totalItems = filteredOptions.length + (showCreateOption ? 1 : 0);
|
|
80
|
+
|
|
81
|
+
// Reset highlighted index when filtered options change
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setHighlightedIndex(0);
|
|
84
|
+
}, [searchTerm, showCreateOption]);
|
|
85
|
+
|
|
86
|
+
// Clear pending delete when user starts typing
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (searchTerm !== '') {
|
|
89
|
+
setPendingDeleteIndex(null);
|
|
90
|
+
}
|
|
91
|
+
}, [searchTerm]);
|
|
92
|
+
|
|
93
|
+
// Scroll highlighted item into view
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (isOpen && highlightedItemRef.current && dropdownRef.current) {
|
|
96
|
+
highlightedItemRef.current.scrollIntoView({
|
|
97
|
+
block: 'nearest',
|
|
98
|
+
behavior: 'smooth'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}, [highlightedIndex, isOpen]);
|
|
102
|
+
|
|
103
|
+
// Clear search term when popover closes and refocus input when it opens
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!isOpen) {
|
|
106
|
+
setSearchTerm('');
|
|
107
|
+
} else {
|
|
108
|
+
// Ensure input stays focused when popover opens
|
|
109
|
+
inputRef.current?.focus();
|
|
110
|
+
}
|
|
111
|
+
}, [isOpen]);
|
|
112
|
+
|
|
113
|
+
const handleSelect = (option: string) => {
|
|
114
|
+
onChange([...value, option]);
|
|
115
|
+
setSearchTerm('');
|
|
116
|
+
setIsOpen(false);
|
|
117
|
+
setHighlightedIndex(0);
|
|
118
|
+
setPendingDeleteIndex(null);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleCreate = (newTag: string) => {
|
|
122
|
+
// Add to value
|
|
123
|
+
onChange([...value, newTag]);
|
|
124
|
+
// Add to options if callback provided
|
|
125
|
+
if (onOptionsChange && !options.includes(newTag)) {
|
|
126
|
+
onOptionsChange([...options, newTag]);
|
|
127
|
+
}
|
|
128
|
+
setSearchTerm('');
|
|
129
|
+
setIsOpen(false);
|
|
130
|
+
setHighlightedIndex(0);
|
|
131
|
+
setPendingDeleteIndex(null);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleRemove = (option: string, e: React.MouseEvent) => {
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
onChange(value.filter(v => v !== option));
|
|
137
|
+
setPendingDeleteIndex(null);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
141
|
+
// Handle special keys
|
|
142
|
+
if (e.key === 'Enter' && isOpen && totalItems > 0) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
// Check if we're selecting the create option
|
|
146
|
+
if (highlightedIndex === filteredOptions.length && showCreateOption) {
|
|
147
|
+
handleCreate(searchTerm);
|
|
148
|
+
} else if (highlightedIndex < filteredOptions.length) {
|
|
149
|
+
handleSelect(filteredOptions[highlightedIndex]);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (e.key === 'ArrowDown') {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.stopPropagation();
|
|
157
|
+
if (!isOpen) {
|
|
158
|
+
setIsOpen(true);
|
|
159
|
+
} else {
|
|
160
|
+
setHighlightedIndex(prev =>
|
|
161
|
+
prev < totalItems - 1 ? prev + 1 : prev
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (e.key === 'ArrowUp') {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
if (!isOpen) {
|
|
171
|
+
setIsOpen(true);
|
|
172
|
+
} else {
|
|
173
|
+
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0));
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (e.key === 'Escape') {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
setIsOpen(false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (e.key === 'Backspace' && searchTerm === '' && value.length > 0) {
|
|
186
|
+
// Two-step deletion: first backspace marks for deletion, second backspace deletes
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
const lastIndex = value.length - 1;
|
|
189
|
+
|
|
190
|
+
if (pendingDeleteIndex === lastIndex) {
|
|
191
|
+
// Second backspace: actually delete the item
|
|
192
|
+
onChange(value.slice(0, -1));
|
|
193
|
+
setPendingDeleteIndex(null);
|
|
194
|
+
} else {
|
|
195
|
+
// First backspace: mark for deletion
|
|
196
|
+
setPendingDeleteIndex(lastIndex);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// For any other key (typing characters), open dropdown
|
|
202
|
+
if (!isOpen && e.key.length === 1) {
|
|
203
|
+
setIsOpen(true);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
if (!disabled) {
|
|
210
|
+
setIsOpen(true);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handleInputFocus = () => {
|
|
215
|
+
if (!disabled) {
|
|
216
|
+
setIsOpen(true);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleContainerClick = () => {
|
|
221
|
+
if (!disabled) {
|
|
222
|
+
inputRef.current?.focus();
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div className={clsx('relative', className)}>
|
|
228
|
+
<PopoverTrigger asChild>
|
|
229
|
+
<div
|
|
230
|
+
ref={triggerRef}
|
|
231
|
+
className={clsx(
|
|
232
|
+
'min-h-[40px] w-full rounded-md border border-input bg-background px-3 py-2',
|
|
233
|
+
'flex items-center gap-1',
|
|
234
|
+
layout === 'horizontal' ? 'flex-wrap' : 'flex-col items-stretch',
|
|
235
|
+
'cursor-text',
|
|
236
|
+
'ring-offset-background',
|
|
237
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
238
|
+
isOpen ? 'ring-1 ring-inset ring-ring' : ''
|
|
239
|
+
)}
|
|
240
|
+
onClick={handleContainerClick}
|
|
241
|
+
>
|
|
242
|
+
{/* Selected Items Badges - Vertical Layout */}
|
|
243
|
+
{layout === 'vertical' && value.length > 0 && (
|
|
244
|
+
<div className="flex flex-col gap-1 w-full">
|
|
245
|
+
{value.map((item, index) => (
|
|
246
|
+
<span
|
|
247
|
+
key={item}
|
|
248
|
+
className={clsx(
|
|
249
|
+
"inline-flex items-center justify-between gap-2 px-2 py-1 text-sm bg-primary/10 text-primary rounded-md w-full transition-all",
|
|
250
|
+
pendingDeleteIndex === index && "ring-2 ring-red-300 shadow-[0_0_8px_rgba(252,165,165,0.5)]"
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
<span className="truncate">{item}</span>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={(e) => handleRemove(item, e)}
|
|
257
|
+
disabled={disabled}
|
|
258
|
+
className="hover:bg-primary/20 rounded-sm transition-colors flex-shrink-0"
|
|
259
|
+
>
|
|
260
|
+
<X className="h-3 w-3" />
|
|
261
|
+
</button>
|
|
262
|
+
</span>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* Selected Items Badges - Horizontal Layout */}
|
|
268
|
+
{layout === 'horizontal' && value.map((item, index) => (
|
|
269
|
+
<span
|
|
270
|
+
key={item}
|
|
271
|
+
className={clsx(
|
|
272
|
+
"inline-flex items-center gap-1 px-2 py-1 text-sm bg-primary/10 text-primary rounded-md transition-all",
|
|
273
|
+
pendingDeleteIndex === index && "ring-2 ring-red-300 shadow-[0_0_8px_rgba(252,165,165,0.5)]"
|
|
274
|
+
)}
|
|
275
|
+
>
|
|
276
|
+
{item}
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={(e) => handleRemove(item, e)}
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
className="hover:bg-primary/20 rounded-sm transition-colors"
|
|
282
|
+
>
|
|
283
|
+
<X className="h-3 w-3" />
|
|
284
|
+
</button>
|
|
285
|
+
</span>
|
|
286
|
+
))}
|
|
287
|
+
|
|
288
|
+
{/* Search Input */}
|
|
289
|
+
<input
|
|
290
|
+
ref={inputRef}
|
|
291
|
+
type="text"
|
|
292
|
+
value={searchTerm}
|
|
293
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
294
|
+
onKeyDown={handleKeyDown}
|
|
295
|
+
onClick={handleInputClick}
|
|
296
|
+
onFocus={handleInputFocus}
|
|
297
|
+
disabled={disabled}
|
|
298
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
299
|
+
className={clsx(
|
|
300
|
+
'flex-1 min-w-[120px] bg-transparent text-sm',
|
|
301
|
+
'placeholder:text-muted-foreground',
|
|
302
|
+
'border-none outline-none focus:outline-none focus:ring-0 p-0 m-0',
|
|
303
|
+
layout === 'vertical' && 'w-full'
|
|
304
|
+
)}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
</PopoverTrigger>
|
|
308
|
+
|
|
309
|
+
<PopoverContent
|
|
310
|
+
style={{ width: `${width}px` }}
|
|
311
|
+
className="p-0 bg-popover border border-border shadow-lg"
|
|
312
|
+
align="start"
|
|
313
|
+
side="bottom"
|
|
314
|
+
onOpenAutoFocus={(e) => {
|
|
315
|
+
// Prevent the popover from stealing focus from the input
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
<div ref={dropdownRef} className="overflow-y-auto" style={{ maxHeight: `${maxDropdownHeight}px` }}>
|
|
320
|
+
{filteredOptions.length === 0 && !showCreateOption ? (
|
|
321
|
+
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
322
|
+
{searchTerm ? 'No options found' : 'No more options available'}
|
|
323
|
+
</div>
|
|
324
|
+
) : (
|
|
325
|
+
<>
|
|
326
|
+
{filteredOptions.length > 0 && (
|
|
327
|
+
<ul className="py-1">
|
|
328
|
+
{filteredOptions.map((option, index) => (
|
|
329
|
+
<li
|
|
330
|
+
key={option}
|
|
331
|
+
ref={(el) => {
|
|
332
|
+
if (index === highlightedIndex) {
|
|
333
|
+
highlightedItemRef.current = el;
|
|
334
|
+
}
|
|
335
|
+
}}
|
|
336
|
+
onClick={() => handleSelect(option)}
|
|
337
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
338
|
+
className={clsx(
|
|
339
|
+
'px-3 py-2 text-sm cursor-pointer transition-colors',
|
|
340
|
+
index === highlightedIndex
|
|
341
|
+
? 'bg-blue-500/20 text-foreground'
|
|
342
|
+
: 'hover:bg-accent/50'
|
|
343
|
+
)}
|
|
344
|
+
>
|
|
345
|
+
{option}
|
|
346
|
+
</li>
|
|
347
|
+
))}
|
|
348
|
+
</ul>
|
|
349
|
+
)}
|
|
350
|
+
{showCreateOption && (
|
|
351
|
+
<>
|
|
352
|
+
{filteredOptions.length > 0 && (
|
|
353
|
+
<div className="border-t border-border" />
|
|
354
|
+
)}
|
|
355
|
+
<div
|
|
356
|
+
ref={(el) => {
|
|
357
|
+
if (highlightedIndex === filteredOptions.length) {
|
|
358
|
+
highlightedItemRef.current = el;
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
onClick={() => handleCreate(searchTerm)}
|
|
362
|
+
onMouseEnter={() => setHighlightedIndex(filteredOptions.length)}
|
|
363
|
+
className={clsx(
|
|
364
|
+
'px-3 py-2 text-sm cursor-pointer transition-colors text-primary',
|
|
365
|
+
highlightedIndex === filteredOptions.length
|
|
366
|
+
? 'bg-blue-500/20'
|
|
367
|
+
: 'hover:bg-accent/50'
|
|
368
|
+
)}
|
|
369
|
+
>
|
|
370
|
+
{createText.replace('%value%', searchTerm)}
|
|
371
|
+
</div>
|
|
372
|
+
</>
|
|
373
|
+
)}
|
|
374
|
+
</>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</PopoverContent>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function TagsInput(props: TagsInputProps) {
|
|
383
|
+
return (
|
|
384
|
+
<Popover click>
|
|
385
|
+
<TagsInputContent {...props} />
|
|
386
|
+
</Popover>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -18,6 +18,7 @@ export * from "./MessageBox.js";
|
|
|
18
18
|
export * from "./Modal.js";
|
|
19
19
|
export * from "./NumberInput.js";
|
|
20
20
|
export * from "./Overlay.js";
|
|
21
|
+
export * from "./Panel.js";
|
|
21
22
|
export * from "./popup/index.js";
|
|
22
23
|
export * from "./Portal.js";
|
|
23
24
|
export * from "./RadioGroup.js";
|
|
@@ -26,12 +27,12 @@ export * from "./SelectList.js";
|
|
|
26
27
|
export * from "./SelectStack.js";
|
|
27
28
|
export * from "./shadcn/index.js";
|
|
28
29
|
export * from "./SidePanel.js";
|
|
29
|
-
export * from "./Panel.js";
|
|
30
30
|
export * from "./Spinner.js";
|
|
31
31
|
export * from "./styles.js";
|
|
32
32
|
export * from "./Switch.js";
|
|
33
33
|
export * from "./table/index.js";
|
|
34
34
|
export * from "./tabs/index.js";
|
|
35
|
+
export * from "./TagsInput.js";
|
|
35
36
|
export * from "./toast/index.js";
|
|
36
37
|
|
|
37
38
|
export type HeroIcon = React.ForwardRefExoticComponent<Omit<React.SVGProps<SVGSVGElement>, "ref"> & {
|
|
@@ -5,13 +5,13 @@ import { cn } from "../libs/utils"
|
|
|
5
5
|
import { JSX } from "react";
|
|
6
6
|
import { useIsInModal } from "./dialog";
|
|
7
7
|
|
|
8
|
-
interface PopoverContextValue {
|
|
8
|
+
export interface PopoverContextValue {
|
|
9
9
|
open: boolean;
|
|
10
10
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
11
11
|
hover: boolean;
|
|
12
12
|
click: boolean;
|
|
13
13
|
}
|
|
14
|
-
const PopoverContext = React.createContext<PopoverContextValue | null>(null);
|
|
14
|
+
export const PopoverContext = React.createContext<PopoverContextValue | null>(null);
|
|
15
15
|
|
|
16
16
|
interface PopoverProps {
|
|
17
17
|
_open?: boolean;
|
|
@@ -4,17 +4,28 @@ import React, { ReactNode, useContext, useEffect, useState } from "react";
|
|
|
4
4
|
//type KeysNotOfType<T, V> = { [K in keyof T]-?: T[K] extends V ? never : K }[keyof T];
|
|
5
5
|
|
|
6
6
|
export class Property<V = any> {
|
|
7
|
-
_value?: V;
|
|
8
|
-
watchers: ((value: V) => void)[] = [];
|
|
9
|
-
|
|
7
|
+
private _value?: V;
|
|
8
|
+
private watchers: ((value: V | undefined) => void)[] = [];
|
|
9
|
+
/**
|
|
10
|
+
* Optional name for debugging purposes.
|
|
11
|
+
* When provided, changes to this property will be logged to the console in development mode,
|
|
12
|
+
* making it easier to track state changes and debug reactive updates.
|
|
13
|
+
*
|
|
14
|
+
* Example: new Property<string>('', 'streamingText')
|
|
15
|
+
* Will log: [CompositeState] streamingText: "" → "new value"
|
|
16
|
+
*/
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
|
|
19
|
+
constructor(value?: V, name?: string) {
|
|
10
20
|
this._value = value;
|
|
21
|
+
this.name = name;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
get value() {
|
|
14
25
|
return this._value;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
set value(value:
|
|
28
|
+
set value(value: V | undefined) {
|
|
18
29
|
if (value !== this._value) {
|
|
19
30
|
this._value = value;
|
|
20
31
|
for (const watcher of this.watchers) {
|
|
@@ -23,11 +34,11 @@ export class Property<V = any> {
|
|
|
23
34
|
}
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
watch(watcher: (value:
|
|
37
|
+
watch(watcher: (value: V | undefined) => void) {
|
|
27
38
|
this.watchers.push(watcher);
|
|
28
39
|
return () => {
|
|
29
40
|
this.watchers = this.watchers.filter(w => w !== watcher);
|
|
30
|
-
}
|
|
41
|
+
};
|
|
31
42
|
}
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -140,3 +151,142 @@ export function useDefineSlot(slot: Slot, value: ReactNode | undefined) {
|
|
|
140
151
|
}
|
|
141
152
|
}, [slot, value])
|
|
142
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Computed property that derives its value from other properties.
|
|
157
|
+
* Automatically recalculates when any of its dependencies change.
|
|
158
|
+
*
|
|
159
|
+
* Think of it like a spreadsheet formula: if cell A1 = 5 and A2 = 10,
|
|
160
|
+
* then A3 = A1 + A2 will automatically update to 15. If A1 changes to 7,
|
|
161
|
+
* A3 automatically becomes 17.
|
|
162
|
+
*
|
|
163
|
+
* @example Basic usage
|
|
164
|
+
* ```typescript
|
|
165
|
+
* class MyState {
|
|
166
|
+
* count = new Property(0, 'count');
|
|
167
|
+
* multiplier = new Property(2, 'multiplier');
|
|
168
|
+
*
|
|
169
|
+
* // Automatically recalculates when count or multiplier changes
|
|
170
|
+
* result = new ComputedProperty(
|
|
171
|
+
* () => (this.count.value || 0) * (this.multiplier.value || 0),
|
|
172
|
+
* [this.count, this.multiplier],
|
|
173
|
+
* 'result'
|
|
174
|
+
* );
|
|
175
|
+
* }
|
|
176
|
+
*
|
|
177
|
+
* state.count.value = 5; // result automatically becomes 10
|
|
178
|
+
* state.multiplier.value = 3; // result automatically becomes 15
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* @example Derived state
|
|
182
|
+
* ```typescript
|
|
183
|
+
* class EditorState {
|
|
184
|
+
* workingCopy = new Property<Interaction>();
|
|
185
|
+
* sourceInteraction = new Property<Interaction>();
|
|
186
|
+
*
|
|
187
|
+
* // Automatically true when workingCopy differs from source
|
|
188
|
+
* isDirty = new ComputedProperty(
|
|
189
|
+
* () => JSON.stringify(this.workingCopy.value) !==
|
|
190
|
+
* JSON.stringify(this.sourceInteraction.value),
|
|
191
|
+
* [this.workingCopy, this.sourceInteraction],
|
|
192
|
+
* 'isDirty'
|
|
193
|
+
* );
|
|
194
|
+
* }
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* @example Cascading computed properties
|
|
198
|
+
* ```typescript
|
|
199
|
+
* class State {
|
|
200
|
+
* a = new Property(1);
|
|
201
|
+
* b = new Property(2);
|
|
202
|
+
* sum = new ComputedProperty(() => a.value + b.value, [a, b]);
|
|
203
|
+
* doubled = new ComputedProperty(() => sum.value * 2, [sum]);
|
|
204
|
+
* }
|
|
205
|
+
* ```
|
|
206
|
+
*
|
|
207
|
+
* Benefits:
|
|
208
|
+
* - ✅ Automatic updates when dependencies change
|
|
209
|
+
* - ✅ Memoization - only recalculates when needed
|
|
210
|
+
* - ✅ Composable - can depend on other ComputedProperties
|
|
211
|
+
* - ✅ Type-safe with full TypeScript support
|
|
212
|
+
*
|
|
213
|
+
* When to use:
|
|
214
|
+
* - ✅ For derived state (values calculated from other values)
|
|
215
|
+
* - ✅ When dependencies are other Properties or ComputedProperties
|
|
216
|
+
* - ❌ NOT for async operations (use regular methods instead)
|
|
217
|
+
* - ❌ NOT if compute function has side effects
|
|
218
|
+
*
|
|
219
|
+
* @important Remember to call dispose() when the ComputedProperty is no longer needed
|
|
220
|
+
* to prevent memory leaks by unsubscribing from all dependencies.
|
|
221
|
+
*/
|
|
222
|
+
export class ComputedProperty<V = any> {
|
|
223
|
+
private _value?: V;
|
|
224
|
+
private watchers: ((value: V | undefined) => void)[] = [];
|
|
225
|
+
private unsubscribers: (() => void)[] = [];
|
|
226
|
+
/**
|
|
227
|
+
* Optional name for debugging purposes.
|
|
228
|
+
* When provided, recalculations will be logged to the console in development mode.
|
|
229
|
+
*/
|
|
230
|
+
readonly name?: string;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param compute - Function that calculates the derived value
|
|
234
|
+
* @param dependencies - Array of Properties this computed value depends on
|
|
235
|
+
* @param name - Optional name for debugging
|
|
236
|
+
*/
|
|
237
|
+
constructor(
|
|
238
|
+
private compute: () => V,
|
|
239
|
+
dependencies: Property<any>[],
|
|
240
|
+
name?: string
|
|
241
|
+
) {
|
|
242
|
+
this.name = name;
|
|
243
|
+
this.recalculate();
|
|
244
|
+
|
|
245
|
+
// Watch all dependencies - when any changes, recalculate
|
|
246
|
+
for (const dep of dependencies) {
|
|
247
|
+
this.unsubscribers.push(
|
|
248
|
+
dep.watch(() => this.recalculate())
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private recalculate() {
|
|
254
|
+
const newValue = this.compute();
|
|
255
|
+
if (newValue !== this._value) {
|
|
256
|
+
this._value = newValue;
|
|
257
|
+
for (const watcher of this.watchers) {
|
|
258
|
+
watcher(newValue);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
get value() {
|
|
264
|
+
return this._value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
watch(watcher: (value: V | undefined) => void) {
|
|
268
|
+
this.watchers.push(watcher);
|
|
269
|
+
return () => {
|
|
270
|
+
this.watchers = this.watchers.filter(w => w !== watcher);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Dispose of this ComputedProperty by unsubscribing from all dependencies.
|
|
276
|
+
* Call this when the ComputedProperty is no longer needed to prevent memory leaks.
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* ```typescript
|
|
280
|
+
* const computed = new ComputedProperty(...);
|
|
281
|
+
*
|
|
282
|
+
* // Later, when cleaning up:
|
|
283
|
+
* computed.dispose();
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
dispose() {
|
|
287
|
+
for (const unsub of this.unsubscribers) {
|
|
288
|
+
unsub();
|
|
289
|
+
}
|
|
290
|
+
this.watchers = [];
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/env/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface EnvProps {
|
|
|
12
12
|
endpoints: {
|
|
13
13
|
zeno: string,
|
|
14
14
|
studio: string,
|
|
15
|
+
sts: string, // Security Token Service endpoint
|
|
15
16
|
},
|
|
16
17
|
firebase?: {
|
|
17
18
|
apiKey: string,
|
|
@@ -20,7 +21,7 @@ export interface EnvProps {
|
|
|
20
21
|
appId?: string,
|
|
21
22
|
providerType?: string,
|
|
22
23
|
},
|
|
23
|
-
datadog
|
|
24
|
+
datadog?: boolean,
|
|
24
25
|
logger?: {
|
|
25
26
|
info: (msg: string, ...args: any) => void,
|
|
26
27
|
warn: (msg: string, ...args: any) => void,
|
|
@@ -93,7 +94,7 @@ export class VertesiaEnvironment implements Readonly<EnvProps> {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
get datadog() {
|
|
96
|
-
return this.
|
|
97
|
+
return this._props?.datadog ?? false;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
get logger() {
|
|
@@ -117,4 +118,4 @@ export class VertesiaEnvironment implements Readonly<EnvProps> {
|
|
|
117
118
|
|
|
118
119
|
const Env = new VertesiaEnvironment();
|
|
119
120
|
|
|
120
|
-
export { Env };
|
|
121
|
+
export { Env };
|