1
- import { useRef , useState , type ComponentProps } from 'react' ;
1
+ import { useRef , useState , type ComponentProps , type ReactNode } from 'react' ;
2
2
import { Root , Portal , Content , Item , Trigger } from '@radix-ui/react-context-menu' ;
3
+ import * as RadixDialog from '@radix-ui/react-dialog' ;
3
4
import picomatch from 'picomatch/posix' ;
4
- import type { FileDescriptor , I18n } from '@tutorialkit/types' ;
5
+ import { interpolateString , type FileDescriptor , type I18n } from '@tutorialkit/types' ;
5
6
6
7
interface FileChangeEvent {
7
8
type : FileDescriptor [ 'type' ] ;
@@ -28,45 +29,69 @@ interface Props extends ComponentProps<'div'> {
28
29
position ?: 'before' | 'after' ;
29
30
30
31
/** Localized texts for menu. */
31
- i18n ?: Pick < I18n , 'fileTreeCreateFileText' | 'fileTreeCreateFolderText' > ;
32
+ i18n ?: Pick <
33
+ I18n ,
34
+ | 'fileTreeCreateFileText'
35
+ | 'fileTreeCreateFolderText'
36
+ | 'fileTreeFailedToCreateFileText'
37
+ | 'fileTreeFailedToCreateFolderText'
38
+ | 'fileTreeAllowedPatternsText'
39
+ > ;
32
40
33
41
/** Props for trigger wrapper. */
34
42
triggerProps ?: ComponentProps < 'div' > & { 'data-testid' ?: string } ;
35
43
}
36
44
45
+ const i18nDefaults = {
46
+ fileTreeFailedToCreateFileText : 'Failed to create file "${filename}".' ,
47
+ fileTreeFailedToCreateFolderText : 'Failed to create folder "${filename}".' ,
48
+ fileTreeCreateFileText : 'Create file' ,
49
+ fileTreeCreateFolderText : 'Create folder' ,
50
+ fileTreeAllowedPatternsText : 'Allowed patterns are:' ,
51
+ } as const satisfies Props [ 'i18n' ] ;
52
+
37
53
export function ContextMenu ( {
38
54
onFileChange,
39
55
allowEditPatterns = [ '**' ] ,
40
56
directory,
41
- i18n,
57
+ i18n : i18nProps ,
42
58
position = 'before' ,
43
59
children,
44
60
triggerProps,
45
61
...props
46
62
} : Props ) {
47
- const [ state , setState ] = useState < 'idle' | 'add_file' | 'add_folder' > ( 'idle' ) ;
63
+ const [ state , setState ] = useState < 'idle' | 'add_file' | 'add_folder' | { error : string } > ( 'idle' ) ;
48
64
const inputRef = useRef < HTMLInputElement > ( null ) ;
49
65
66
+ const error = typeof state === 'string' ? false : state . error ;
67
+ const i18n = { ...i18nProps , ...i18nDefaults } ;
68
+
50
69
if ( ! onFileChange ) {
51
70
return children ;
52
71
}
53
72
54
73
function onFileNameEnd ( event : React . KeyboardEvent < HTMLInputElement > | React . FocusEvent < HTMLInputElement > ) {
74
+ if ( state !== 'add_file' && state !== 'add_folder' ) {
75
+ return ;
76
+ }
77
+
55
78
const name = event . currentTarget . value ;
56
79
57
80
if ( name ) {
58
81
const value = `${ directory } /${ name } ` ;
59
82
const isAllowed = picomatch . isMatch ( value , allowEditPatterns ) ;
83
+ const isFile = state === 'add_file' ;
60
84
61
85
if ( isAllowed ) {
62
86
onFileChange ?.( {
63
87
value,
64
- type : state === 'add_file' ? 'file' : 'folder' ,
88
+ type : isFile ? 'file' : 'folder' ,
65
89
method : 'add' ,
66
90
} ) ;
67
91
} else {
68
- // TODO: Use `@radix-ui/react-dialog` instead
69
- alert ( `File "${ value } " is not allowed. Allowed patterns: [${ allowEditPatterns . join ( ', ' ) } ].` ) ;
92
+ const text = isFile ? i18n . fileTreeFailedToCreateFileText : i18n . fileTreeFailedToCreateFolderText ;
93
+
94
+ return setState ( { error : interpolateString ( text , { filename : value } ) } ) ;
70
95
}
71
96
}
72
97
@@ -118,14 +143,31 @@ export function ContextMenu({
118
143
className = "border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2"
119
144
>
120
145
< MenuItem icon = "i-ph-file-plus" onClick = { ( ) => setState ( 'add_file' ) } >
121
- { i18n ? .fileTreeCreateFileText || 'Create file' }
146
+ { i18n . fileTreeCreateFileText }
122
147
</ MenuItem >
123
148
124
149
< MenuItem icon = "i-ph-folder-plus" onClick = { ( ) => setState ( 'add_folder' ) } >
125
- { i18n ? .fileTreeCreateFolderText || 'Create folder' }
150
+ { i18n . fileTreeCreateFolderText }
126
151
</ MenuItem >
127
152
</ Content >
128
153
</ Portal >
154
+
155
+ { error && (
156
+ < Dialog onClose = { ( ) => setState ( 'idle' ) } >
157
+ < p className = "mb-2" > { error } </ p >
158
+
159
+ < div >
160
+ { i18n . fileTreeAllowedPatternsText }
161
+ < ul className = "list-disc ml-4 mt-2" >
162
+ { allowEditPatterns . map ( ( pattern ) => (
163
+ < li key = { pattern } >
164
+ < code > { pattern } </ code >
165
+ </ li >
166
+ ) ) }
167
+ </ ul >
168
+ </ div >
169
+ </ Dialog >
170
+ ) }
129
171
</ Root >
130
172
) ;
131
173
}
@@ -141,3 +183,25 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp
141
183
</ Item >
142
184
) ;
143
185
}
186
+
187
+ function Dialog ( { onClose, children } : { onClose : ( ) => void ; children : ReactNode } ) {
188
+ return (
189
+ < RadixDialog . Root open = { true } onOpenChange = { ( open ) => ! open && onClose ( ) } >
190
+ < RadixDialog . Portal >
191
+ < RadixDialog . Overlay className = "fixed inset-0 opacity-50 bg-black" />
192
+
193
+ < RadixDialog . Content className = "fixed top-50% left-50% transform-translate--50% w-90vw max-w-450px max-h-85vh rounded-xl text-tk-text-primary bg-tk-background-negative" >
194
+ < div className = "relative py-4 px-10" >
195
+ < RadixDialog . Title className = "text-6 mb-2" > Error</ RadixDialog . Title >
196
+
197
+ { children }
198
+
199
+ < RadixDialog . Close title = "Close" className = "absolute top-4 right-4 w-6 h-6" >
200
+ < span aria-hidden className = "i-ph-x block w-full h-full" > </ span >
201
+ </ RadixDialog . Close >
202
+ </ div >
203
+ </ RadixDialog . Content >
204
+ </ RadixDialog . Portal >
205
+ </ RadixDialog . Root >
206
+ ) ;
207
+ }
0 commit comments