create-nextjs-cms 0.7.6 → 0.7.8
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
CHANGED
|
@@ -95,7 +95,13 @@ export default function FormInputs({ inputs, sectionName }: { inputs: FieldClien
|
|
|
95
95
|
case 'password':
|
|
96
96
|
return <PasswordFormInput input={input as PasswordFieldClientConfig} key={input.name} />
|
|
97
97
|
case 'tags':
|
|
98
|
-
return
|
|
98
|
+
return (
|
|
99
|
+
<TagsFormInput
|
|
100
|
+
input={input as TagsFieldClientConfig}
|
|
101
|
+
sectionName={sectionName}
|
|
102
|
+
key={input.name}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
99
105
|
case 'map':
|
|
100
106
|
return <MapFormInput input={input as MapFieldClientConfig} key={input.name} />
|
|
101
107
|
case 'checkbox':
|
|
@@ -4,20 +4,32 @@ import { CheckboxFieldClientConfig } from 'nextjs-cms/core/fields'
|
|
|
4
4
|
import { useController, useFormContext } from 'react-hook-form'
|
|
5
5
|
import { Switch } from '@/components/ui/switch'
|
|
6
6
|
|
|
7
|
+
function toChecked(value: unknown): boolean {
|
|
8
|
+
if (value === true) return true
|
|
9
|
+
if (value === 1) return true
|
|
10
|
+
if (value === '1') return true
|
|
11
|
+
if (value === 'true') return true
|
|
12
|
+
if (value === 'on') return true
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export default function CheckboxFormInput({ input }: { input: CheckboxFieldClientConfig }) {
|
|
8
17
|
const { control } = useFormContext()
|
|
18
|
+
const initialValue =
|
|
19
|
+
input.value !== undefined && input.value !== null ? toChecked(input.value) : toChecked(input.defaultValue)
|
|
20
|
+
|
|
9
21
|
const {
|
|
10
22
|
field,
|
|
11
|
-
fieldState: {
|
|
23
|
+
fieldState: { error },
|
|
12
24
|
} = useController({
|
|
13
25
|
name: input.name,
|
|
14
26
|
control,
|
|
15
|
-
defaultValue:
|
|
27
|
+
defaultValue: initialValue,
|
|
16
28
|
})
|
|
17
29
|
return (
|
|
18
30
|
<FormInputElement
|
|
19
31
|
validationError={error}
|
|
20
|
-
value={
|
|
32
|
+
value={initialValue}
|
|
21
33
|
readonly={input.readonly}
|
|
22
34
|
label={input.label}
|
|
23
35
|
required={input.required}
|
|
@@ -25,8 +37,9 @@ export default function CheckboxFormInput({ input }: { input: CheckboxFieldClien
|
|
|
25
37
|
<Switch
|
|
26
38
|
ref={field.ref}
|
|
27
39
|
onCheckedChange={field.onChange}
|
|
28
|
-
|
|
40
|
+
checked={toChecked(field.value)}
|
|
29
41
|
name={field.name}
|
|
42
|
+
disabled={input.readonly}
|
|
30
43
|
/>
|
|
31
44
|
</FormInputElement>
|
|
32
45
|
)
|
|
@@ -5,10 +5,17 @@ import { useController, useFormContext } from 'react-hook-form'
|
|
|
5
5
|
import { Badge } from '@/components/ui/badge'
|
|
6
6
|
import { Input } from '@/components/ui/input'
|
|
7
7
|
import { X } from 'lucide-react'
|
|
8
|
-
import { useState, useRef } from 'react'
|
|
8
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
9
9
|
import { cn } from '@/lib/utils'
|
|
10
|
+
import { trpc } from '@/app/_trpc/client'
|
|
10
11
|
|
|
11
|
-
export default function TagsFormInput({
|
|
12
|
+
export default function TagsFormInput({
|
|
13
|
+
input,
|
|
14
|
+
sectionName,
|
|
15
|
+
}: {
|
|
16
|
+
input: TagsFieldClientConfig
|
|
17
|
+
sectionName: string
|
|
18
|
+
}) {
|
|
12
19
|
const t = useI18n()
|
|
13
20
|
const { control } = useFormContext()
|
|
14
21
|
const {
|
|
@@ -22,13 +29,40 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
|
|
|
22
29
|
|
|
23
30
|
const [inputValue, setInputValue] = useState('')
|
|
24
31
|
const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
|
|
32
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
33
|
+
const [showDropdown, setShowDropdown] = useState(false)
|
|
25
34
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
35
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
36
|
+
const [debouncedQuery, setDebouncedQuery] = useState('')
|
|
37
|
+
|
|
38
|
+
// Debounce the query input
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!input.hasAutoCompletion) return
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
setDebouncedQuery(inputValue)
|
|
43
|
+
}, 300)
|
|
44
|
+
return () => clearTimeout(timer)
|
|
45
|
+
}, [inputValue, input.hasAutoCompletion])
|
|
46
|
+
|
|
47
|
+
const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
|
|
48
|
+
{ sectionName, fieldName: input.name, query: debouncedQuery },
|
|
49
|
+
{ enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
|
|
50
|
+
)
|
|
26
51
|
|
|
27
52
|
// Parse tags from comma-separated string
|
|
28
|
-
const getTags = (): string[] => {
|
|
53
|
+
const getTags = useCallback((): string[] => {
|
|
29
54
|
if (!field.value || typeof field.value !== 'string') return []
|
|
30
55
|
return field.value.split(',').filter((tag) => tag.trim() !== '')
|
|
31
|
-
}
|
|
56
|
+
}, [field.value])
|
|
57
|
+
|
|
58
|
+
// Filter out already-added tags from suggestions
|
|
59
|
+
const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
|
|
60
|
+
|
|
61
|
+
// Show/hide dropdown based on suggestions
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
|
|
64
|
+
setHighlightedIndex(-1)
|
|
65
|
+
}, [filteredSuggestions.length, inputValue.length])
|
|
32
66
|
|
|
33
67
|
// Convert tags array to comma-separated string
|
|
34
68
|
const setTags = (tags: string[]) => {
|
|
@@ -49,6 +83,7 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
|
|
|
49
83
|
const newTags = [...existingTags, trimmedTag]
|
|
50
84
|
setTags(newTags)
|
|
51
85
|
setInputValue('')
|
|
86
|
+
setShowDropdown(false)
|
|
52
87
|
}
|
|
53
88
|
}
|
|
54
89
|
}
|
|
@@ -59,6 +94,34 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
|
|
|
59
94
|
}
|
|
60
95
|
|
|
61
96
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
97
|
+
if (showDropdown && filteredSuggestions.length > 0) {
|
|
98
|
+
if (event.key === 'ArrowDown') {
|
|
99
|
+
event.preventDefault()
|
|
100
|
+
setHighlightedIndex((prev) =>
|
|
101
|
+
prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (event.key === 'ArrowUp') {
|
|
106
|
+
event.preventDefault()
|
|
107
|
+
setHighlightedIndex((prev) =>
|
|
108
|
+
prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (event.key === 'Enter' && highlightedIndex >= 0) {
|
|
113
|
+
event.preventDefault()
|
|
114
|
+
addTag(filteredSuggestions[highlightedIndex])
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (event.key === 'Escape') {
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
setShowDropdown(false)
|
|
120
|
+
setHighlightedIndex(-1)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
if (event.key === 'Enter' || event.key === ',') {
|
|
63
126
|
event.preventDefault()
|
|
64
127
|
if (inputValue.trim()) {
|
|
@@ -81,6 +144,14 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
|
|
|
81
144
|
inputRef.current?.focus()
|
|
82
145
|
}
|
|
83
146
|
|
|
147
|
+
const handleBlur = () => {
|
|
148
|
+
// Small delay to allow click events on dropdown items to fire first
|
|
149
|
+
setTimeout(() => {
|
|
150
|
+
setShowDropdown(false)
|
|
151
|
+
setHighlightedIndex(-1)
|
|
152
|
+
}, 200)
|
|
153
|
+
}
|
|
154
|
+
|
|
84
155
|
const tags = getTags()
|
|
85
156
|
|
|
86
157
|
return (
|
|
@@ -101,53 +172,88 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
|
|
|
101
172
|
ref={field.ref}
|
|
102
173
|
/>
|
|
103
174
|
|
|
104
|
-
<div
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
<div className='relative'>
|
|
176
|
+
<div
|
|
177
|
+
className={cn(
|
|
178
|
+
'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors hover:ring-gray-400 focus-within:ring-blue-500',
|
|
179
|
+
'flex min-h-9 flex-wrap items-center gap-1 text-sm',
|
|
180
|
+
'cursor-text',
|
|
181
|
+
field.disabled && 'cursor-not-allowed opacity-50',
|
|
182
|
+
)}
|
|
183
|
+
onClick={handleContainerClick}
|
|
184
|
+
>
|
|
185
|
+
{tags.map((tag, index) => (
|
|
186
|
+
<Badge
|
|
187
|
+
key={`${tag}-${index}`}
|
|
188
|
+
variant='secondary'
|
|
189
|
+
className={cn(
|
|
190
|
+
'flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200 border-foreground/40',
|
|
191
|
+
highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<span>{tag}</span>
|
|
195
|
+
{!field.disabled ? (
|
|
196
|
+
<button
|
|
197
|
+
type='button'
|
|
198
|
+
onClick={(e) => {
|
|
199
|
+
e.stopPropagation()
|
|
200
|
+
removeTag(tag)
|
|
201
|
+
}}
|
|
202
|
+
className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
|
|
203
|
+
aria-label={`Remove ${tag} tag`}
|
|
204
|
+
>
|
|
205
|
+
<X className='h-3 w-3' />
|
|
206
|
+
</button>
|
|
207
|
+
) : null}
|
|
208
|
+
</Badge>
|
|
209
|
+
))}
|
|
210
|
+
|
|
211
|
+
<Input
|
|
212
|
+
ref={inputRef}
|
|
213
|
+
type='text'
|
|
214
|
+
value={inputValue}
|
|
215
|
+
onChange={handleInputChange}
|
|
216
|
+
onKeyDown={handleKeyDown}
|
|
217
|
+
onBlur={handleBlur}
|
|
218
|
+
placeholder={
|
|
219
|
+
tags.length === 0
|
|
220
|
+
? input.placeholder
|
|
221
|
+
? input.placeholder
|
|
222
|
+
: input.hasAutoCompletion
|
|
223
|
+
? t('startTypingForSuggestions')
|
|
224
|
+
: t('startTyping')
|
|
225
|
+
: ''
|
|
226
|
+
}
|
|
227
|
+
className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
|
228
|
+
disabled={field.disabled}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{showDropdown && filteredSuggestions.length > 0 && (
|
|
233
|
+
<div
|
|
234
|
+
ref={dropdownRef}
|
|
235
|
+
className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
|
|
121
236
|
>
|
|
122
|
-
|
|
123
|
-
{!field.disabled ? (
|
|
237
|
+
{filteredSuggestions.map((suggestion, index) => (
|
|
124
238
|
<button
|
|
239
|
+
key={suggestion}
|
|
125
240
|
type='button'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
241
|
+
className={cn(
|
|
242
|
+
'text-popover-foreground w-full px-3 py-2 text-left text-sm',
|
|
243
|
+
'hover:bg-accent hover:text-accent-foreground',
|
|
244
|
+
highlightedIndex === index && 'bg-accent text-accent-foreground',
|
|
245
|
+
)}
|
|
246
|
+
onMouseDown={(e) => {
|
|
247
|
+
e.preventDefault()
|
|
248
|
+
addTag(suggestion)
|
|
129
249
|
}}
|
|
130
|
-
|
|
131
|
-
aria-label={`Remove ${tag} tag`}
|
|
250
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
132
251
|
>
|
|
133
|
-
|
|
252
|
+
{suggestion}
|
|
134
253
|
</button>
|
|
135
|
-
)
|
|
136
|
-
</
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
<Input
|
|
140
|
-
ref={inputRef}
|
|
141
|
-
type='text'
|
|
142
|
-
value={inputValue}
|
|
143
|
-
onChange={handleInputChange}
|
|
144
|
-
onKeyDown={handleKeyDown}
|
|
145
|
-
placeholder={
|
|
146
|
-
tags.length === 0 ? (input.placeholder ? input.placeholder : t('startTyping')) : ''
|
|
147
|
-
}
|
|
148
|
-
className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
|
149
|
-
disabled={field.disabled}
|
|
150
|
-
/>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
151
257
|
</div>
|
|
152
258
|
</FormInputElement>
|
|
153
259
|
)
|