@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.
Files changed (40) hide show
  1. package/dist/editor.js +14575 -13938
  2. package/package.json +1 -1
  3. package/src/build-processor.ts +1 -1
  4. package/src/collection-scanner.ts +49 -2
  5. package/src/dev-middleware.ts +1 -1
  6. package/src/editor/components/attribute-editor.tsx +0 -1
  7. package/src/editor/components/bg-image-overlay.tsx +7 -8
  8. package/src/editor/components/block-editor.tsx +12 -12
  9. package/src/editor/components/collections-browser.tsx +10 -10
  10. package/src/editor/components/create-page-modal.tsx +18 -18
  11. package/src/editor/components/delete-page-dialog.tsx +4 -3
  12. package/src/editor/components/field-utils.ts +54 -0
  13. package/src/editor/components/fields.tsx +254 -72
  14. package/src/editor/components/frontmatter-fields.tsx +135 -54
  15. package/src/editor/components/frontmatter-sidebar.tsx +55 -58
  16. package/src/editor/components/link-edit-popover.tsx +10 -5
  17. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  18. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  19. package/src/editor/components/mdx-block-view.tsx +4 -4
  20. package/src/editor/components/mdx-component-picker.tsx +2 -2
  21. package/src/editor/components/media-library.tsx +19 -18
  22. package/src/editor/components/modal-shell.tsx +16 -3
  23. package/src/editor/components/prop-editor.tsx +15 -18
  24. package/src/editor/components/redirects-manager.tsx +42 -35
  25. package/src/editor/components/reference-picker.tsx +5 -4
  26. package/src/editor/components/seo-editor.tsx +36 -27
  27. package/src/editor/components/toolbar.tsx +50 -33
  28. package/src/editor/dom.ts +13 -2
  29. package/src/editor/editor.ts +7 -6
  30. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  31. package/src/editor/index.tsx +7 -6
  32. package/src/editor/signals.ts +44 -13
  33. package/src/editor/strings.ts +123 -0
  34. package/src/editor/styles.css +75 -2
  35. package/src/editor/types.ts +8 -0
  36. package/src/index.ts +6 -0
  37. package/src/source-finder/image-finder.ts +1 -1
  38. package/src/source-finder/search-index.ts +12 -4
  39. package/src/source-finder/snippet-utils.ts +4 -1
  40. 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
- <label class="text-xs font-medium text-white/70">{label}</label>
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-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
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 (text input + Browse button)
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, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
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-1.5 min-w-0">
172
+ <div class="space-y-2 min-w-0">
101
173
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
102
- {hasImage && (
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
- class="relative w-full rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
105
- onClick={onBrowse}
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
- <img
109
- src={value}
110
- alt={label}
111
- class="w-full h-auto max-h-48"
112
- onError={(e) => {
113
- ;(e.target as HTMLImageElement).style.display = 'none'
114
- }}
115
- />
116
- <div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
117
- <span class="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">Change</span>
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="shrink-0 px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
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
- Browse
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-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
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-1.5">
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-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
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 bg-white rounded-full transition-transform shadow-sm pointer-events-none',
271
- isOn && 'translate-x-4',
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
- <input
301
- type="number"
302
- value={value ?? ''}
303
- placeholder={placeholder}
304
- min={min}
305
- max={max}
306
- step={step}
307
- required={required}
308
- onInput={(e) => {
309
- const val = (e.target as HTMLInputElement).value
310
- onChange(val === '' ? undefined : Number(val))
311
- }}
312
- class={cn(
313
- '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',
314
- isDirty
315
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
316
- : 'border-white/20 focus:border-white/40 focus:ring-white/10',
317
- )}
318
- data-cms-ui
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
- return (
535
+ const dropdown = (
372
536
  <div
373
537
  ref={ref}
374
- class={cn('overflow-y-auto bg-cms-dark shadow-lg', className)}
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
- {children}
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-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
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-cms-primary transition-colors cursor-pointer"
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-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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
  )}