@nuasite/cms 0.39.1 → 0.40.0
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/dist/editor.js +14575 -13938
- package/package.json +1 -1
- package/src/build-processor.ts +1 -1
- package/src/collection-scanner.ts +49 -2
- package/src/dev-middleware.ts +1 -1
- package/src/editor/components/attribute-editor.tsx +0 -1
- package/src/editor/components/bg-image-overlay.tsx +7 -8
- package/src/editor/components/block-editor.tsx +12 -12
- package/src/editor/components/collections-browser.tsx +10 -10
- package/src/editor/components/create-page-modal.tsx +18 -18
- package/src/editor/components/delete-page-dialog.tsx +4 -3
- package/src/editor/components/field-utils.ts +54 -0
- package/src/editor/components/fields.tsx +254 -72
- package/src/editor/components/frontmatter-fields.tsx +135 -54
- package/src/editor/components/frontmatter-sidebar.tsx +55 -58
- package/src/editor/components/link-edit-popover.tsx +10 -5
- package/src/editor/components/markdown-editor-overlay.tsx +100 -39
- package/src/editor/components/markdown-inline-editor.tsx +58 -26
- package/src/editor/components/mdx-block-view.tsx +4 -4
- package/src/editor/components/mdx-component-picker.tsx +2 -2
- package/src/editor/components/media-library.tsx +19 -18
- package/src/editor/components/modal-shell.tsx +16 -3
- package/src/editor/components/prop-editor.tsx +15 -18
- package/src/editor/components/redirects-manager.tsx +42 -35
- package/src/editor/components/reference-picker.tsx +5 -4
- package/src/editor/components/seo-editor.tsx +36 -27
- package/src/editor/components/toolbar.tsx +50 -33
- package/src/editor/dom.ts +13 -2
- package/src/editor/editor.ts +7 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
- package/src/editor/index.tsx +7 -6
- package/src/editor/signals.ts +44 -13
- package/src/editor/strings.ts +123 -0
- package/src/editor/styles.css +75 -2
- package/src/editor/types.ts +8 -0
- package/src/index.ts +6 -0
- package/src/source-finder/image-finder.ts +1 -1
- package/src/source-finder/search-index.ts +12 -4
- package/src/source-finder/snippet-utils.ts +4 -1
- package/src/types.ts +4 -0
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
|
+
import { createPortal } from 'preact/compat'
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
4
|
import { getDropdownPosition } from '../constants'
|
|
4
5
|
import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
|
|
5
6
|
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
6
7
|
import { cn } from '../lib/cn'
|
|
8
|
+
import { uploadMedia } from '../markdown-api'
|
|
9
|
+
import { config, showToast } from '../signals'
|
|
10
|
+
import { STRINGS } from '../strings'
|
|
7
11
|
|
|
8
12
|
// ============================================================================
|
|
9
13
|
// Field Label
|
|
10
14
|
// ============================================================================
|
|
11
15
|
|
|
12
|
-
export function FieldLabel({ label, isDirty, onReset }: { label: string; isDirty?: boolean; onReset?: () => void }) {
|
|
16
|
+
export function FieldLabel({ label, isDirty, onReset, tooltip }: { label: string; isDirty?: boolean; onReset?: () => void; tooltip?: string }) {
|
|
13
17
|
return (
|
|
14
18
|
<div class="flex items-center justify-between">
|
|
15
|
-
<
|
|
19
|
+
<div class="flex items-center gap-1.5">
|
|
20
|
+
<label class="text-xs font-medium text-white/70">{label}</label>
|
|
21
|
+
{tooltip && (
|
|
22
|
+
<span class="relative group/tt inline-flex" data-cms-ui>
|
|
23
|
+
<svg class="w-3.5 h-3.5 text-white/40 hover:text-white/70 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
24
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
25
|
+
</svg>
|
|
26
|
+
<span class="absolute left-0 top-full mt-1 w-64 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tt:opacity-100 group-hover/tt:visible transition-all z-50 pointer-events-none whitespace-normal">
|
|
27
|
+
{tooltip}
|
|
28
|
+
</span>
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
16
32
|
{isDirty && (
|
|
17
33
|
<div class="flex items-center gap-1.5">
|
|
18
34
|
<span class="text-xs text-cms-primary font-medium">Modified</span>
|
|
@@ -50,14 +66,15 @@ export interface TextFieldProps {
|
|
|
50
66
|
onReset?: () => void
|
|
51
67
|
inputType?: string
|
|
52
68
|
required?: boolean
|
|
69
|
+
tooltip?: string
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
export function TextField(
|
|
56
|
-
{ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
|
|
73
|
+
{ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required, tooltip }: TextFieldProps,
|
|
57
74
|
) {
|
|
58
75
|
return (
|
|
59
76
|
<div class="space-y-1.5">
|
|
60
|
-
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
77
|
+
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} tooltip={tooltip} />
|
|
61
78
|
<input
|
|
62
79
|
type={inputType}
|
|
63
80
|
value={value ?? ''}
|
|
@@ -69,7 +86,7 @@ export function TextField(
|
|
|
69
86
|
class={cn(
|
|
70
87
|
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
71
88
|
isDirty
|
|
72
|
-
? 'border-
|
|
89
|
+
? 'border-white/30 focus:border-white/40 focus:ring-white/10'
|
|
73
90
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
74
91
|
)}
|
|
75
92
|
data-cms-ui
|
|
@@ -79,67 +96,170 @@ export function TextField(
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
// ============================================================================
|
|
82
|
-
// Image Field (
|
|
99
|
+
// Image Field — drop-zone preview (click/drag to upload) + "Choose from library" link
|
|
83
100
|
// ============================================================================
|
|
84
101
|
|
|
85
102
|
export interface ImageFieldProps {
|
|
86
103
|
label: string
|
|
87
104
|
value: string | undefined
|
|
88
|
-
placeholder?: string
|
|
89
105
|
onChange: (value: string) => void
|
|
90
106
|
onBrowse: () => void
|
|
91
107
|
isDirty?: boolean
|
|
92
108
|
onReset?: () => void
|
|
93
|
-
required?: boolean
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
export function ImageField({ label, value,
|
|
111
|
+
export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
|
|
97
112
|
const hasImage = !!value && value.length > 0
|
|
113
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
114
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
115
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
116
|
+
// Track the src that failed so the fallback resets automatically when `value` changes
|
|
117
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
|
118
|
+
const showFallback = hasImage && failedSrc === value
|
|
119
|
+
|
|
120
|
+
const handleUploadClick = useCallback(() => {
|
|
121
|
+
fileInputRef.current?.click()
|
|
122
|
+
}, [])
|
|
123
|
+
|
|
124
|
+
const uploadFile = useCallback(async (file: File) => {
|
|
125
|
+
const cfg = config.value
|
|
126
|
+
if (!cfg) {
|
|
127
|
+
showToast(STRINGS.media.notConfigured, 'error')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
setIsUploading(true)
|
|
131
|
+
try {
|
|
132
|
+
const result = await uploadMedia(cfg, file)
|
|
133
|
+
if (result.success && result.url) {
|
|
134
|
+
onChange(result.url)
|
|
135
|
+
showToast(STRINGS.media.fileUploaded, 'success')
|
|
136
|
+
} else {
|
|
137
|
+
showToast(result.error || STRINGS.media.uploadFailed, 'error')
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
showToast(STRINGS.media.uploadFailed, 'error')
|
|
141
|
+
} finally {
|
|
142
|
+
setIsUploading(false)
|
|
143
|
+
}
|
|
144
|
+
}, [onChange])
|
|
145
|
+
|
|
146
|
+
const handleFileChange = useCallback(async (e: Event) => {
|
|
147
|
+
const target = e.target as HTMLInputElement
|
|
148
|
+
const file = target.files?.[0]
|
|
149
|
+
if (file) await uploadFile(file)
|
|
150
|
+
target.value = ''
|
|
151
|
+
}, [uploadFile])
|
|
152
|
+
|
|
153
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
if (e.dataTransfer?.types.includes('Files')) setIsDragOver(true)
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
159
|
+
// Only clear on actually leaving the drop-zone, not crossing child boundaries
|
|
160
|
+
if ((e.currentTarget as Node).contains(e.relatedTarget as Node | null)) return
|
|
161
|
+
setIsDragOver(false)
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
const handleDrop = useCallback(async (e: DragEvent) => {
|
|
165
|
+
e.preventDefault()
|
|
166
|
+
setIsDragOver(false)
|
|
167
|
+
const file = e.dataTransfer?.files?.[0]
|
|
168
|
+
if (file && file.type.startsWith('image/')) await uploadFile(file)
|
|
169
|
+
}, [uploadFile])
|
|
98
170
|
|
|
99
171
|
return (
|
|
100
|
-
<div class="space-y-
|
|
172
|
+
<div class="space-y-2 min-w-0">
|
|
101
173
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
102
|
-
|
|
174
|
+
<input
|
|
175
|
+
ref={fileInputRef}
|
|
176
|
+
type="file"
|
|
177
|
+
accept="image/*"
|
|
178
|
+
class="hidden"
|
|
179
|
+
onChange={handleFileChange}
|
|
180
|
+
data-cms-ui
|
|
181
|
+
/>
|
|
182
|
+
<div class="w-full max-w-sm space-y-2">
|
|
103
183
|
<div
|
|
104
|
-
|
|
105
|
-
|
|
184
|
+
role="button"
|
|
185
|
+
tabIndex={isUploading ? -1 : 0}
|
|
186
|
+
aria-label={hasImage ? 'Replace image — click to upload or drop a file' : 'Upload image — click or drop a file'}
|
|
187
|
+
aria-busy={isUploading}
|
|
188
|
+
onClick={isUploading ? undefined : handleUploadClick}
|
|
189
|
+
onKeyDown={(e) => {
|
|
190
|
+
if (isUploading) return
|
|
191
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
192
|
+
e.preventDefault()
|
|
193
|
+
handleUploadClick()
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
onDragOver={handleDragOver}
|
|
197
|
+
onDragEnter={handleDragOver}
|
|
198
|
+
onDragLeave={handleDragLeave}
|
|
199
|
+
onDrop={handleDrop}
|
|
200
|
+
class={cn(
|
|
201
|
+
'relative w-full rounded-cms-sm overflow-hidden bg-white/5 border group transition-colors focus:outline-none focus:ring-1 focus:ring-white/30',
|
|
202
|
+
isUploading ? 'cursor-wait' : 'cursor-pointer',
|
|
203
|
+
isDragOver ? 'border-cms-primary bg-cms-primary/10' : 'border-white/10 hover:border-white/20',
|
|
204
|
+
)}
|
|
106
205
|
data-cms-ui
|
|
107
206
|
>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
207
|
+
{hasImage && !showFallback
|
|
208
|
+
? (
|
|
209
|
+
<>
|
|
210
|
+
<img
|
|
211
|
+
src={value}
|
|
212
|
+
alt={label}
|
|
213
|
+
class="w-full h-32 object-contain"
|
|
214
|
+
onError={() => setFailedSrc(value ?? null)}
|
|
215
|
+
/>
|
|
216
|
+
<div class="absolute inset-x-0 bottom-0 px-2 py-1.5 bg-linear-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
|
217
|
+
<span class="block text-white text-[11px] font-medium truncate" title={value}>
|
|
218
|
+
{value}
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={(e) => {
|
|
224
|
+
e.stopPropagation()
|
|
225
|
+
onChange('')
|
|
226
|
+
}}
|
|
227
|
+
class="absolute top-1.5 right-1.5 w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-red-500/80 text-white rounded-cms-xs opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all cursor-pointer z-20"
|
|
228
|
+
title="Remove image from this field"
|
|
229
|
+
data-cms-ui
|
|
230
|
+
>
|
|
231
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
232
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
233
|
+
</svg>
|
|
234
|
+
</button>
|
|
235
|
+
</>
|
|
236
|
+
)
|
|
237
|
+
: (
|
|
238
|
+
<div class="w-full h-32 flex flex-col items-center justify-center gap-1 text-white/25 group-hover:text-white/40 transition-colors">
|
|
239
|
+
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
|
240
|
+
<path
|
|
241
|
+
stroke-linecap="round"
|
|
242
|
+
stroke-linejoin="round"
|
|
243
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
244
|
+
/>
|
|
245
|
+
</svg>
|
|
246
|
+
{showFallback && <span class="text-[11px] font-medium" title={value}>Image failed to load</span>}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
{/* Hover overlay — decorative hint (pointer-events-none, click goes to the parent role=button) */}
|
|
250
|
+
<div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
|
|
251
|
+
<span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
|
252
|
+
{isUploading ? 'Uploading…' : isDragOver ? 'Drop to upload' : hasImage ? 'Replace image' : 'Click or drop file'}
|
|
253
|
+
</span>
|
|
118
254
|
</div>
|
|
119
255
|
</div>
|
|
120
|
-
)}
|
|
121
|
-
<div class="flex gap-2 min-w-0">
|
|
122
|
-
<input
|
|
123
|
-
type="text"
|
|
124
|
-
value={value ?? ''}
|
|
125
|
-
placeholder={placeholder}
|
|
126
|
-
required={required}
|
|
127
|
-
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
128
|
-
class={cn(
|
|
129
|
-
'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
130
|
-
isDirty
|
|
131
|
-
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
132
|
-
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
133
|
-
)}
|
|
134
|
-
data-cms-ui
|
|
135
|
-
/>
|
|
136
256
|
<button
|
|
137
257
|
type="button"
|
|
138
258
|
onClick={onBrowse}
|
|
139
|
-
class="
|
|
259
|
+
class="block text-xs text-white/50 hover:text-white underline decoration-white/20 hover:decoration-white underline-offset-2 transition-colors cursor-pointer"
|
|
140
260
|
data-cms-ui
|
|
141
261
|
>
|
|
142
|
-
|
|
262
|
+
Choose from library
|
|
143
263
|
</button>
|
|
144
264
|
</div>
|
|
145
265
|
</div>
|
|
@@ -186,7 +306,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
|
|
|
186
306
|
class={cn(
|
|
187
307
|
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
188
308
|
isDirty
|
|
189
|
-
? 'border-
|
|
309
|
+
? 'border-white/30 focus:border-white/40 focus:ring-white/10'
|
|
190
310
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
191
311
|
)}
|
|
192
312
|
data-cms-ui
|
|
@@ -212,7 +332,7 @@ export interface SelectFieldProps {
|
|
|
212
332
|
|
|
213
333
|
export function SelectField({ label, value, options, onChange, isDirty, onReset, allowEmpty = true }: SelectFieldProps) {
|
|
214
334
|
return (
|
|
215
|
-
<div class="space-y-
|
|
335
|
+
<div class="space-y-2">
|
|
216
336
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
217
337
|
<select
|
|
218
338
|
value={value ?? ''}
|
|
@@ -220,7 +340,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
|
|
|
220
340
|
class={cn(
|
|
221
341
|
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
|
|
222
342
|
isDirty
|
|
223
|
-
? 'border-
|
|
343
|
+
? 'border-white/30 focus:border-white/40 focus:ring-white/10'
|
|
224
344
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
225
345
|
)}
|
|
226
346
|
data-cms-ui
|
|
@@ -267,9 +387,12 @@ export function ToggleField({ label, value, onChange, isDirty, onReset }: Toggle
|
|
|
267
387
|
>
|
|
268
388
|
<span
|
|
269
389
|
class={cn(
|
|
270
|
-
'absolute top-0.5 left-0.5 w-4 h-4
|
|
271
|
-
isOn
|
|
390
|
+
'absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm pointer-events-none',
|
|
391
|
+
isOn ? 'translate-x-4 bg-[#404040]' : 'translate-x-0 bg-white',
|
|
272
392
|
)}
|
|
393
|
+
style={{
|
|
394
|
+
transition: 'transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 200ms ease-out',
|
|
395
|
+
}}
|
|
273
396
|
/>
|
|
274
397
|
</button>
|
|
275
398
|
</div>
|
|
@@ -294,29 +417,63 @@ export interface NumberFieldProps {
|
|
|
294
417
|
}
|
|
295
418
|
|
|
296
419
|
export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
|
|
420
|
+
const stepValue = step ?? 1
|
|
421
|
+
const adjust = (delta: number) => {
|
|
422
|
+
const current = typeof value === 'number' ? value : 0
|
|
423
|
+
let next = current + delta * stepValue
|
|
424
|
+
if (typeof min === 'number' && next < min) next = min
|
|
425
|
+
if (typeof max === 'number' && next > max) next = max
|
|
426
|
+
onChange(next)
|
|
427
|
+
}
|
|
297
428
|
return (
|
|
298
429
|
<div class="space-y-1.5">
|
|
299
430
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
300
|
-
<
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
431
|
+
<div class="relative">
|
|
432
|
+
<input
|
|
433
|
+
type="number"
|
|
434
|
+
value={value ?? ''}
|
|
435
|
+
placeholder={placeholder}
|
|
436
|
+
min={min}
|
|
437
|
+
max={max}
|
|
438
|
+
step={step}
|
|
439
|
+
required={required}
|
|
440
|
+
onInput={(e) => {
|
|
441
|
+
const val = (e.target as HTMLInputElement).value
|
|
442
|
+
onChange(val === '' ? undefined : Number(val))
|
|
443
|
+
}}
|
|
444
|
+
class={cn(
|
|
445
|
+
'w-full pl-3 pr-12 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
446
|
+
isDirty
|
|
447
|
+
? 'border-white/30 focus:border-white/40 focus:ring-white/10'
|
|
448
|
+
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
449
|
+
)}
|
|
450
|
+
data-cms-ui
|
|
451
|
+
/>
|
|
452
|
+
<div class="absolute right-1.5 top-1/4 -translate-y-1/2 flex gap-[3px]">
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
onClick={() => adjust(-1)}
|
|
456
|
+
class="w-5 h-5 flex items-center justify-center bg-cms-primary hover:bg-cms-primary-hover text-cms-dark rounded-cms-xs transition-colors cursor-pointer"
|
|
457
|
+
title="Decrease"
|
|
458
|
+
data-cms-ui
|
|
459
|
+
>
|
|
460
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
|
461
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
|
462
|
+
</svg>
|
|
463
|
+
</button>
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={() => adjust(1)}
|
|
467
|
+
class="w-5 h-5 flex items-center justify-center bg-cms-primary hover:bg-cms-primary-hover text-cms-dark rounded-cms-xs transition-colors cursor-pointer"
|
|
468
|
+
title="Increase"
|
|
469
|
+
data-cms-ui
|
|
470
|
+
>
|
|
471
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
|
472
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
|
|
473
|
+
</svg>
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
320
477
|
</div>
|
|
321
478
|
)
|
|
322
479
|
}
|
|
@@ -360,6 +517,13 @@ export interface DropdownPanelProps {
|
|
|
360
517
|
* Fixed-position dropdown container that escapes parent overflow clipping.
|
|
361
518
|
* Handles outside-click and Escape-key dismissal.
|
|
362
519
|
*/
|
|
520
|
+
function getCmsPortalTarget(): HTMLElement | null {
|
|
521
|
+
if (typeof document === 'undefined') return null
|
|
522
|
+
const host = document.getElementById('cms-app-host')
|
|
523
|
+
if (!host?.shadowRoot) return null
|
|
524
|
+
return host.shadowRoot.querySelector('.cms-root') as HTMLElement | null
|
|
525
|
+
}
|
|
526
|
+
|
|
363
527
|
export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, children, className, panelRef, exemptRefs }: DropdownPanelProps) {
|
|
364
528
|
const internalRef = useRef<HTMLDivElement>(null)
|
|
365
529
|
const ref = panelRef ?? internalRef
|
|
@@ -368,16 +532,34 @@ export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, ch
|
|
|
368
532
|
|
|
369
533
|
if (!isOpen) return null
|
|
370
534
|
|
|
371
|
-
|
|
535
|
+
const dropdown = (
|
|
372
536
|
<div
|
|
373
537
|
ref={ref}
|
|
374
|
-
class={cn('
|
|
538
|
+
class={cn('flex flex-col bg-cms-dark shadow-lg', className)}
|
|
375
539
|
style={getDropdownPosition(triggerRef.current, maxHeight)}
|
|
376
540
|
data-cms-ui
|
|
377
541
|
>
|
|
378
|
-
|
|
542
|
+
<div class="flex justify-end p-1 shrink-0 border-b border-white/5">
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
onClick={onClose}
|
|
546
|
+
class="w-5 h-5 flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 rounded-cms-xs transition-colors"
|
|
547
|
+
title="Close"
|
|
548
|
+
data-cms-ui
|
|
549
|
+
>
|
|
550
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
551
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
552
|
+
</svg>
|
|
553
|
+
</button>
|
|
554
|
+
</div>
|
|
555
|
+
<div class="overflow-y-auto flex-1 min-h-0">
|
|
556
|
+
{children}
|
|
557
|
+
</div>
|
|
379
558
|
</div>
|
|
380
559
|
)
|
|
560
|
+
|
|
561
|
+
const portalTarget = getCmsPortalTarget()
|
|
562
|
+
return portalTarget ? createPortal(dropdown, portalTarget) : dropdown
|
|
381
563
|
}
|
|
382
564
|
|
|
383
565
|
// ============================================================================
|
|
@@ -465,7 +647,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
465
647
|
class={cn(
|
|
466
648
|
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
467
649
|
isDirty
|
|
468
|
-
? 'border-
|
|
650
|
+
? 'border-white/30 focus:border-white/40 focus:ring-white/10'
|
|
469
651
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
470
652
|
)}
|
|
471
653
|
data-cms-ui
|
|
@@ -569,7 +751,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
|
|
|
569
751
|
<button
|
|
570
752
|
type="button"
|
|
571
753
|
onClick={() => toggleOption(val)}
|
|
572
|
-
class="text-cms-primary/60 hover:text-
|
|
754
|
+
class="text-cms-primary/60 hover:text-red-400 transition-colors cursor-pointer"
|
|
573
755
|
data-cms-ui
|
|
574
756
|
>
|
|
575
757
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -634,7 +816,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
|
|
|
634
816
|
)}
|
|
635
817
|
>
|
|
636
818
|
{isSelected && (
|
|
637
|
-
<svg class="w-3 h-3 text-
|
|
819
|
+
<svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
638
820
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
639
821
|
</svg>
|
|
640
822
|
)}
|