@tradejs/app 1.0.6 → 1.0.9

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.
@@ -17,6 +17,21 @@ import {
17
17
  Text,
18
18
  } from '@chakra-ui/react';
19
19
  import { FiEdit2, FiSettings } from 'react-icons/fi';
20
+ import {
21
+ AI_RESPONSE_LANGUAGE_OPTIONS,
22
+ normalizeAiResponseLanguage,
23
+ } from '@tradejs/infra/aiLanguages';
24
+ import {
25
+ AI_CUSTOM_ENDPOINT_VALUE,
26
+ AI_ENDPOINT_OPTIONS,
27
+ normalizeAiEndpoint,
28
+ } from '@tradejs/infra/aiEndpoints';
29
+ import {
30
+ AI_CUSTOM_MODEL_VALUE,
31
+ getAiModelOptionsForEndpoint,
32
+ hasPresetAiModelsForEndpoint,
33
+ normalizeAiModel,
34
+ } from '@tradejs/infra/aiModels';
20
35
  import { toaster } from '@UI';
21
36
 
22
37
  type SettingsResponse = {
@@ -26,13 +41,14 @@ type SettingsResponse = {
26
41
  apiKey: string;
27
42
  apiSecret: string;
28
43
  };
29
- token: string;
30
44
  coinalyze: {
31
45
  apiKey: string;
32
46
  };
33
- openai: {
47
+ ai: {
34
48
  apiKey: string;
35
49
  apiEndpoint: string;
50
+ model: string;
51
+ responseLanguage: string;
36
52
  };
37
53
  telegram: {
38
54
  botToken: string;
@@ -49,10 +65,11 @@ type SettingsViewState = {
49
65
  userName: string;
50
66
  bybitApiKey: string;
51
67
  bybitApiSecret: string;
52
- token: string;
53
68
  coinalyzeApiKey: string;
54
- openAIApiKey: string;
55
- openAIApiEndpoint: string;
69
+ aiApiKey: string;
70
+ aiApiEndpoint: string;
71
+ aiModel: string;
72
+ aiResponseLanguage: string;
56
73
  tgBotToken: string;
57
74
  tgChatId: string;
58
75
  };
@@ -64,20 +81,15 @@ type PasswordState = {
64
81
  confirmPassword: string;
65
82
  };
66
83
 
67
- type SectionName =
68
- | 'bybit'
69
- | 'password'
70
- | 'token'
71
- | 'coinalyze'
72
- | 'openai'
73
- | 'telegram';
84
+ type SectionName = 'bybit' | 'password' | 'coinalyze' | 'ai' | 'telegram';
74
85
  type EditableField =
75
86
  | 'bybitApiKey'
76
87
  | 'bybitApiSecret'
77
- | 'token'
78
88
  | 'coinalyzeApiKey'
79
- | 'openAIApiKey'
80
- | 'openAIApiEndpoint'
89
+ | 'aiApiKey'
90
+ | 'aiApiEndpoint'
91
+ | 'aiModel'
92
+ | 'aiResponseLanguage'
81
93
  | 'tgBotToken'
82
94
  | 'tgChatId';
83
95
 
@@ -85,10 +97,11 @@ const EMPTY_SETTINGS: SettingsViewState = {
85
97
  userName: '',
86
98
  bybitApiKey: '',
87
99
  bybitApiSecret: '',
88
- token: '',
89
100
  coinalyzeApiKey: '',
90
- openAIApiKey: '',
91
- openAIApiEndpoint: '',
101
+ aiApiKey: '',
102
+ aiApiEndpoint: '',
103
+ aiModel: '',
104
+ aiResponseLanguage: '',
92
105
  tgBotToken: '',
93
106
  tgChatId: '',
94
107
  };
@@ -96,10 +109,11 @@ const EMPTY_SETTINGS: SettingsViewState = {
96
109
  const EMPTY_DRAFTS: SettingsDraftState = {
97
110
  bybitApiKey: '',
98
111
  bybitApiSecret: '',
99
- token: '',
100
112
  coinalyzeApiKey: '',
101
- openAIApiKey: '',
102
- openAIApiEndpoint: '',
113
+ aiApiKey: '',
114
+ aiApiEndpoint: '',
115
+ aiModel: '',
116
+ aiResponseLanguage: '',
103
117
  tgBotToken: '',
104
118
  tgChatId: '',
105
119
  };
@@ -112,10 +126,11 @@ const EMPTY_PASSWORDS: PasswordState = {
112
126
  const EMPTY_EDITING: Record<EditableField, boolean> = {
113
127
  bybitApiKey: false,
114
128
  bybitApiSecret: false,
115
- token: false,
116
129
  coinalyzeApiKey: false,
117
- openAIApiKey: false,
118
- openAIApiEndpoint: false,
130
+ aiApiKey: false,
131
+ aiApiEndpoint: false,
132
+ aiModel: false,
133
+ aiResponseLanguage: false,
119
134
  tgBotToken: false,
120
135
  tgChatId: false,
121
136
  };
@@ -125,18 +140,16 @@ const SECTION_FIELDS: Record<
125
140
  EditableField[]
126
141
  > = {
127
142
  bybit: ['bybitApiKey', 'bybitApiSecret'],
128
- token: ['token'],
129
143
  coinalyze: ['coinalyzeApiKey'],
130
- openai: ['openAIApiKey', 'openAIApiEndpoint'],
144
+ ai: ['aiApiKey', 'aiApiEndpoint', 'aiModel', 'aiResponseLanguage'],
131
145
  telegram: ['tgBotToken', 'tgChatId'],
132
146
  };
133
147
 
134
148
  const MASKED_FIELDS = new Set<EditableField>([
135
149
  'bybitApiKey',
136
150
  'bybitApiSecret',
137
- 'token',
138
151
  'coinalyzeApiKey',
139
- 'openAIApiKey',
152
+ 'aiApiKey',
140
153
  'tgBotToken',
141
154
  ]);
142
155
 
@@ -144,17 +157,31 @@ const toViewState = (payload: SettingsResponse): SettingsViewState => ({
144
157
  userName: payload.userName,
145
158
  bybitApiKey: payload.settings.bybit.apiKey || '',
146
159
  bybitApiSecret: payload.settings.bybit.apiSecret || '',
147
- token: payload.settings.token || '',
148
160
  coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
149
- openAIApiKey: payload.settings.openai.apiKey || '',
150
- openAIApiEndpoint: payload.settings.openai.apiEndpoint || '',
161
+ aiApiKey: payload.settings.ai.apiKey || '',
162
+ aiApiEndpoint:
163
+ normalizeAiEndpoint(payload.settings.ai.apiEndpoint) ||
164
+ AI_ENDPOINT_OPTIONS[0].value,
165
+ aiModel:
166
+ normalizeAiModel(
167
+ payload.settings.ai.model,
168
+ normalizeAiEndpoint(payload.settings.ai.apiEndpoint) ||
169
+ AI_ENDPOINT_OPTIONS[0].value,
170
+ ) ||
171
+ payload.settings.ai.model ||
172
+ '',
173
+ aiResponseLanguage: normalizeAiResponseLanguage(
174
+ payload.settings.ai.responseLanguage,
175
+ ),
151
176
  tgBotToken: payload.settings.telegram.botToken || '',
152
177
  tgChatId: payload.settings.telegram.chatId || '',
153
178
  });
154
179
 
155
180
  const toDraftState = (view: SettingsViewState): SettingsDraftState => ({
156
181
  ...EMPTY_DRAFTS,
157
- openAIApiEndpoint: view.openAIApiEndpoint,
182
+ aiApiEndpoint: view.aiApiEndpoint,
183
+ aiModel: view.aiModel,
184
+ aiResponseLanguage: view.aiResponseLanguage,
158
185
  tgChatId: view.tgChatId,
159
186
  });
160
187
 
@@ -171,6 +198,39 @@ const getErrorMessage = (
171
198
  ? payload.error
172
199
  : fallback;
173
200
 
201
+ const getDraftAiEndpoint = (drafts: SettingsDraftState) =>
202
+ normalizeAiEndpoint(drafts.aiApiEndpoint) || drafts.aiApiEndpoint.trim();
203
+
204
+ const getSelectedAiModelOption = (aiModel: string, aiEndpoint: string) => {
205
+ const trimmedModel = aiModel.trim();
206
+
207
+ return getAiModelOptionsForEndpoint(aiEndpoint).some(
208
+ (option) => option.value === trimmedModel,
209
+ )
210
+ ? trimmedModel
211
+ : AI_CUSTOM_MODEL_VALUE;
212
+ };
213
+
214
+ const DRAWER_SELECT_STYLE = {
215
+ width: '100%',
216
+ height: '40px',
217
+ paddingLeft: '12px',
218
+ paddingRight: '48px',
219
+ borderWidth: '1px',
220
+ borderColor: 'rgba(255, 255, 255, 0.16)',
221
+ borderRadius: '0.375rem',
222
+ background: 'rgba(0, 0, 0, 0.32)',
223
+ appearance: 'none',
224
+ WebkitAppearance: 'none',
225
+ MozAppearance: 'none',
226
+ backgroundImage:
227
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9' fill='none'%3E%3Cpath d='M1 1.5L7 7.5L13 1.5' stroke='%23E5E7EB' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\")",
228
+ backgroundRepeat: 'no-repeat',
229
+ backgroundPosition: 'right 16px center',
230
+ backgroundSize: '14px 9px',
231
+ color: 'rgb(229, 231, 235)',
232
+ } as const;
233
+
174
234
  export const AccountSettingsDrawer = () => {
175
235
  const [open, setOpen] = useState(false);
176
236
  const [loading, setLoading] = useState(false);
@@ -246,10 +306,13 @@ export const AccountSettingsDrawer = () => {
246
306
  return Boolean(passwords.password || passwords.confirmPassword);
247
307
  }
248
308
 
249
- if (section === 'openai') {
309
+ if (section === 'ai') {
310
+ const draftAiEndpoint = getDraftAiEndpoint(drafts);
250
311
  return (
251
- Boolean(drafts.openAIApiKey.trim()) ||
252
- drafts.openAIApiEndpoint !== settings.openAIApiEndpoint
312
+ Boolean(drafts.aiApiKey.trim()) ||
313
+ draftAiEndpoint !== settings.aiApiEndpoint ||
314
+ drafts.aiModel.trim() !== settings.aiModel ||
315
+ drafts.aiResponseLanguage !== settings.aiResponseLanguage
253
316
  );
254
317
  }
255
318
 
@@ -360,46 +423,41 @@ export const AccountSettingsDrawer = () => {
360
423
  apiSecret: getSecretUpdateValue('bybitApiSecret'),
361
424
  },
362
425
  }
363
- : section === 'token'
426
+ : section === 'coinalyze'
364
427
  ? {
365
428
  section,
366
429
  data: {
367
- token: getSecretUpdateValue('token'),
430
+ apiKey: getSecretUpdateValue('coinalyzeApiKey'),
368
431
  },
369
432
  }
370
- : section === 'coinalyze'
433
+ : section === 'ai'
371
434
  ? {
372
435
  section,
373
436
  data: {
374
- apiKey: getSecretUpdateValue('coinalyzeApiKey'),
437
+ apiKey: getSecretUpdateValue('aiApiKey'),
438
+ apiEndpoint: drafts.aiApiEndpoint,
439
+ model: drafts.aiModel.trim(),
440
+ responseLanguage: drafts.aiResponseLanguage,
375
441
  },
376
442
  }
377
- : section === 'openai'
443
+ : section === 'telegram'
378
444
  ? {
379
445
  section,
380
446
  data: {
381
- apiKey: getSecretUpdateValue('openAIApiKey'),
382
- apiEndpoint: getSecretUpdateValue('openAIApiEndpoint'),
447
+ botToken: getSecretUpdateValue('tgBotToken'),
448
+ chatId:
449
+ drafts.tgChatId !== settings.tgChatId
450
+ ? drafts.tgChatId.trim()
451
+ : undefined,
383
452
  },
384
453
  }
385
- : section === 'telegram'
386
- ? {
387
- section,
388
- data: {
389
- botToken: getSecretUpdateValue('tgBotToken'),
390
- chatId:
391
- drafts.tgChatId !== settings.tgChatId
392
- ? drafts.tgChatId.trim()
393
- : undefined,
394
- },
395
- }
396
- : {
397
- section,
398
- data: {
399
- password: passwords.password,
400
- confirmPassword: passwords.confirmPassword,
401
- },
402
- };
454
+ : {
455
+ section,
456
+ data: {
457
+ password: passwords.password,
458
+ confirmPassword: passwords.confirmPassword,
459
+ },
460
+ };
403
461
 
404
462
  const response = await fetch('/api/user/settings', {
405
463
  method: 'PATCH',
@@ -441,6 +499,19 @@ export const AccountSettingsDrawer = () => {
441
499
  }
442
500
  };
443
501
 
502
+ const selectedAiEndpointOption = AI_ENDPOINT_OPTIONS.some(
503
+ (option) => option.value === drafts.aiApiEndpoint,
504
+ )
505
+ ? drafts.aiApiEndpoint
506
+ : AI_CUSTOM_ENDPOINT_VALUE;
507
+ const effectiveAiEndpoint = getDraftAiEndpoint(drafts);
508
+ const hasPresetAiModels = hasPresetAiModelsForEndpoint(effectiveAiEndpoint);
509
+ const aiModelOptions = getAiModelOptionsForEndpoint(effectiveAiEndpoint);
510
+ const selectedAiModelOption = getSelectedAiModelOption(
511
+ drafts.aiModel,
512
+ effectiveAiEndpoint,
513
+ );
514
+
444
515
  const renderEditableField = ({
445
516
  label,
446
517
  field,
@@ -553,14 +624,17 @@ export const AccountSettingsDrawer = () => {
553
624
  </Flex>
554
625
  ) : (
555
626
  <Stack gap={5}>
556
- <Box>
557
- <Text fontSize="sm" color="gray.400">
558
- Signed in as
559
- </Text>
560
- <Text fontSize="lg" fontWeight="600">
627
+ <Text fontSize="sm" color="gray.400">
628
+ Signed in as{' '}
629
+ <Text
630
+ as="span"
631
+ fontSize="sm"
632
+ fontWeight="600"
633
+ color="gray.100"
634
+ >
561
635
  {settings.userName || 'Unknown user'}
562
636
  </Text>
563
- </Box>
637
+ </Text>
564
638
 
565
639
  <Box
566
640
  borderWidth="1px"
@@ -608,27 +682,124 @@ export const AccountSettingsDrawer = () => {
608
682
  >
609
683
  <Stack gap={4}>
610
684
  <Box>
611
- <Text fontWeight="600">OpenAI / LLM</Text>
685
+ <Text fontWeight="600">AI / LLM</Text>
612
686
  <Text fontSize="sm" color="gray.400">
613
- Stored in the user profile and used for AI analysis.
687
+ Stored in the user profile and used for AI analysis
688
+ and user-facing AI replies.
614
689
  </Text>
615
690
  </Box>
691
+ <Field.Root>
692
+ <Field.Label>AI_API_ENDPOINT</Field.Label>
693
+ <select
694
+ value={selectedAiEndpointOption}
695
+ onChange={(event) => {
696
+ const nextValue = event.target.value;
697
+ setDrafts((current) => ({
698
+ ...current,
699
+ aiApiEndpoint:
700
+ nextValue === AI_CUSTOM_ENDPOINT_VALUE
701
+ ? ''
702
+ : nextValue,
703
+ aiModel:
704
+ nextValue === AI_CUSTOM_ENDPOINT_VALUE
705
+ ? ''
706
+ : normalizeAiModel('', nextValue),
707
+ }));
708
+ }}
709
+ style={DRAWER_SELECT_STYLE}
710
+ >
711
+ {AI_ENDPOINT_OPTIONS.map((option) => (
712
+ <option key={option.value} value={option.value}>
713
+ {option.label}
714
+ </option>
715
+ ))}
716
+ </select>
717
+ <Text mt="2" fontSize="sm" color="gray.400">
718
+ {effectiveAiEndpoint || 'Endpoint is not set yet.'}
719
+ </Text>
720
+ </Field.Root>
721
+ {selectedAiEndpointOption === AI_CUSTOM_ENDPOINT_VALUE ? (
722
+ <Field.Root>
723
+ <Field.Label>Custom AI API endpoint URL</Field.Label>
724
+ <Input
725
+ value={drafts.aiApiEndpoint}
726
+ placeholder="https://your-openai-compatible-endpoint/v1"
727
+ onChange={(event) =>
728
+ updateDraft('aiApiEndpoint', event.target.value)
729
+ }
730
+ />
731
+ </Field.Root>
732
+ ) : null}
616
733
  {renderEditableField({
617
- label: 'OPENAI_API_ENDPOINT',
618
- field: 'openAIApiEndpoint',
619
- placeholder: 'Enter a new OpenAI API endpoint',
620
- })}
621
- {renderEditableField({
622
- label: 'OPENAI_API_KEY',
623
- field: 'openAIApiKey',
624
- placeholder: 'Enter a new OpenAI API key',
734
+ label: 'AI_API_KEY',
735
+ field: 'aiApiKey',
736
+ placeholder: 'Enter a new AI API key',
625
737
  })}
738
+ {hasPresetAiModels ? (
739
+ <Field.Root>
740
+ <Field.Label>AI_MODEL</Field.Label>
741
+ <select
742
+ value={selectedAiModelOption}
743
+ onChange={(event) => {
744
+ const nextValue = event.target.value;
745
+ if (nextValue === AI_CUSTOM_MODEL_VALUE) {
746
+ updateDraft('aiModel', '');
747
+ return;
748
+ }
749
+
750
+ updateDraft('aiModel', nextValue);
751
+ }}
752
+ style={DRAWER_SELECT_STYLE}
753
+ >
754
+ {aiModelOptions.map((option) => (
755
+ <option key={option.value} value={option.value}>
756
+ {option.label}
757
+ </option>
758
+ ))}
759
+ <option value={AI_CUSTOM_MODEL_VALUE}>
760
+ Custom model
761
+ </option>
762
+ </select>
763
+ </Field.Root>
764
+ ) : null}
765
+ {!hasPresetAiModels ||
766
+ selectedAiModelOption === AI_CUSTOM_MODEL_VALUE ? (
767
+ <Field.Root>
768
+ <Field.Label>Custom AI model name</Field.Label>
769
+ <Input
770
+ value={drafts.aiModel}
771
+ placeholder="Enter an OpenAI-compatible model name"
772
+ onChange={(event) =>
773
+ updateDraft('aiModel', event.target.value)
774
+ }
775
+ />
776
+ </Field.Root>
777
+ ) : null}
778
+ <Field.Root>
779
+ <Field.Label>AI_RESPONSE_LANGUAGE</Field.Label>
780
+ <select
781
+ value={drafts.aiResponseLanguage}
782
+ onChange={(event) =>
783
+ updateDraft(
784
+ 'aiResponseLanguage',
785
+ event.target.value,
786
+ )
787
+ }
788
+ style={DRAWER_SELECT_STYLE}
789
+ >
790
+ {AI_RESPONSE_LANGUAGE_OPTIONS.map((option) => (
791
+ <option key={option.value} value={option.value}>
792
+ {option.label}
793
+ </option>
794
+ ))}
795
+ </select>
796
+ </Field.Root>
626
797
  <Flex justify="flex-end">
627
798
  <Button
628
799
  colorPalette="teal"
629
- loading={savingSection === 'openai'}
630
- disabled={!isSectionDirty('openai')}
631
- onClick={() => saveSection('openai')}
800
+ loading={savingSection === 'ai'}
801
+ disabled={!isSectionDirty('ai')}
802
+ onClick={() => saveSection('ai')}
632
803
  >
633
804
  Save
634
805
  </Button>
@@ -766,38 +937,6 @@ export const AccountSettingsDrawer = () => {
766
937
  </Flex>
767
938
  </Stack>
768
939
  </Box>
769
-
770
- <Box
771
- borderWidth="1px"
772
- borderColor="gray.700"
773
- borderRadius="lg"
774
- p={4}
775
- bg="gray.900"
776
- >
777
- <Stack gap={4}>
778
- <Box>
779
- <Text fontWeight="600">Passwordless auth token</Text>
780
- <Text fontSize="sm" color="gray.400">
781
- Token accepted in dashboard links for instant auth.
782
- </Text>
783
- </Box>
784
- {renderEditableField({
785
- label: 'TOKEN',
786
- field: 'token',
787
- placeholder: 'Enter a new passwordless token',
788
- })}
789
- <Flex justify="flex-end">
790
- <Button
791
- colorPalette="teal"
792
- loading={savingSection === 'token'}
793
- disabled={!isSectionDirty('token')}
794
- onClick={() => saveSection('token')}
795
- >
796
- Save
797
- </Button>
798
- </Flex>
799
- </Stack>
800
- </Box>
801
940
  </Stack>
802
941
  )}
803
942
  </Drawer.Body>
@@ -8,18 +8,9 @@ import {
8
8
  type IconButtonProps,
9
9
  type SpanProps,
10
10
  } from '@chakra-ui/react';
11
- import { ThemeProvider, useTheme, type ThemeProviderProps } from 'next-themes';
12
11
  import * as React from 'react';
13
12
  import { LuMoon, LuSun } from 'react-icons/lu';
14
13
 
15
- export interface ColorModeProviderProps extends ThemeProviderProps {}
16
-
17
- export function ColorModeProvider(props: ColorModeProviderProps) {
18
- return (
19
- <ThemeProvider attribute="class" disableTransitionOnChange {...props} />
20
- );
21
- }
22
-
23
14
  export type ColorMode = 'light' | 'dark';
24
15
 
25
16
  export interface UseColorModeReturn {
@@ -28,16 +19,72 @@ export interface UseColorModeReturn {
28
19
  toggleColorMode: () => void;
29
20
  }
30
21
 
31
- export function useColorMode(): UseColorModeReturn {
32
- const { resolvedTheme, setTheme } = useTheme();
22
+ export interface ColorModeProviderProps {
23
+ children: React.ReactNode;
24
+ forcedTheme?: ColorMode;
25
+ }
26
+
27
+ const ColorModeContext = React.createContext<UseColorModeReturn | null>(null);
28
+
29
+ export function ColorModeProvider({
30
+ children,
31
+ forcedTheme,
32
+ }: ColorModeProviderProps) {
33
+ const [colorMode, setColorModeState] = React.useState<ColorMode>(
34
+ forcedTheme ?? 'dark',
35
+ );
36
+
37
+ React.useEffect(() => {
38
+ if (!forcedTheme) {
39
+ return;
40
+ }
41
+
42
+ setColorModeState(forcedTheme);
43
+ }, [forcedTheme]);
44
+
45
+ React.useEffect(() => {
46
+ const root = document.documentElement;
47
+ const nextColorMode = forcedTheme ?? colorMode;
48
+ const previousColorMode = nextColorMode === 'dark' ? 'light' : 'dark';
49
+
50
+ root.classList.remove(previousColorMode);
51
+ root.classList.add(nextColorMode);
52
+ root.style.colorScheme = nextColorMode;
53
+ }, [colorMode, forcedTheme]);
54
+
55
+ const setColorMode = (nextColorMode: ColorMode) => {
56
+ if (forcedTheme) {
57
+ return;
58
+ }
59
+
60
+ setColorModeState(nextColorMode);
61
+ };
62
+
33
63
  const toggleColorMode = () => {
34
- setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
64
+ setColorMode(colorMode === 'dark' ? 'light' : 'dark');
35
65
  };
36
- return {
37
- colorMode: resolvedTheme as ColorMode,
38
- setColorMode: setTheme,
66
+
67
+ const value: UseColorModeReturn = {
68
+ colorMode: forcedTheme ?? colorMode,
69
+ setColorMode,
39
70
  toggleColorMode,
40
71
  };
72
+
73
+ return (
74
+ <ColorModeContext.Provider value={value}>
75
+ {children}
76
+ </ColorModeContext.Provider>
77
+ );
78
+ }
79
+
80
+ export function useColorMode(): UseColorModeReturn {
81
+ return (
82
+ React.useContext(ColorModeContext) ?? {
83
+ colorMode: 'dark',
84
+ setColorMode: () => {},
85
+ toggleColorMode: () => {},
86
+ }
87
+ );
41
88
  }
42
89
 
43
90
  export function useColorModeValue<T>(light: T, dark: T) {
@@ -19,6 +19,7 @@ interface SelectProps {
19
19
  multiple?: boolean;
20
20
  size?: 'xs' | 'sm' | 'md' | 'lg';
21
21
  onChange?: (value: string[]) => void;
22
+ onOpenChange?: (open: boolean) => void;
22
23
  }
23
24
 
24
25
  export const Select = ({
@@ -30,6 +31,7 @@ export const Select = ({
30
31
  width = '320px',
31
32
  size = 'sm',
32
33
  onChange,
34
+ onOpenChange,
33
35
  }: SelectProps) => {
34
36
  const collection = useMemo(
35
37
  () =>
@@ -44,6 +46,7 @@ export const Select = ({
44
46
  collection={collection}
45
47
  {...(value ? { value } : { defaultValue })}
46
48
  onValueChange={(details) => onChange?.(details.value)}
49
+ onOpenChange={(details) => onOpenChange?.(details.open)}
47
50
  size={size}
48
51
  multiple={multiple}
49
52
  width={width}
@@ -23,6 +23,7 @@ interface SelectWithSearchProps {
23
23
  multiple?: boolean;
24
24
  size?: 'xs' | 'sm' | 'md' | 'lg';
25
25
  onChange?: (value: string[]) => void;
26
+ onOpenChange?: (open: boolean) => void;
26
27
  }
27
28
 
28
29
  export const SelectWithSearch = ({
@@ -35,6 +36,7 @@ export const SelectWithSearch = ({
35
36
  width = '320px',
36
37
  size = 'sm',
37
38
  onChange,
39
+ onOpenChange,
38
40
  }: SelectWithSearchProps) => {
39
41
  const { contains } = useFilter({ sensitivity: 'base' });
40
42
  const [inputValue, setInputValue] = useState(
@@ -61,6 +63,7 @@ export const SelectWithSearch = ({
61
63
  setInputValue(e.inputValue);
62
64
  }}
63
65
  onOpenChange={(details) => {
66
+ onOpenChange?.(details.open);
64
67
  if (details.open) {
65
68
  filter('');
66
69
  setInputValue('');