@nuasite/cms 0.1.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 (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. package/src/vite-plugin.ts +42 -0
@@ -0,0 +1,387 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { cn } from '../lib/cn'
3
+
4
+ // ============================================================================
5
+ // Field Label
6
+ // ============================================================================
7
+
8
+ export function FieldLabel({ label, isDirty, onReset }: { label: string; isDirty?: boolean; onReset?: () => void }) {
9
+ return (
10
+ <div class="flex items-center justify-between">
11
+ <label class="text-xs font-medium text-white/70">{label}</label>
12
+ {isDirty && (
13
+ <div class="flex items-center gap-1.5">
14
+ <span class="text-xs text-cms-primary font-medium">Modified</span>
15
+ {onReset && (
16
+ <button
17
+ type="button"
18
+ onClick={onReset}
19
+ class="text-white/40 hover:text-white transition-colors cursor-pointer"
20
+ title="Reset to original"
21
+ data-cms-ui
22
+ >
23
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a5 5 0 015 5v2M3 10l4-4m-4 4l4 4" />
25
+ </svg>
26
+ </button>
27
+ )}
28
+ </div>
29
+ )}
30
+ </div>
31
+ )
32
+ }
33
+
34
+ // ============================================================================
35
+ // Text Field
36
+ // ============================================================================
37
+
38
+ export interface TextFieldProps {
39
+ label: string
40
+ value: string | undefined
41
+ placeholder?: string
42
+ onChange: (value: string) => void
43
+ isDirty?: boolean
44
+ onReset?: () => void
45
+ }
46
+
47
+ export function TextField({ label, value, placeholder, onChange, isDirty, onReset }: TextFieldProps) {
48
+ return (
49
+ <div class="space-y-1.5">
50
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
51
+ <input
52
+ type="text"
53
+ value={value ?? ''}
54
+ placeholder={placeholder}
55
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
56
+ class={cn(
57
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
58
+ isDirty
59
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
60
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
61
+ )}
62
+ data-cms-ui
63
+ />
64
+ </div>
65
+ )
66
+ }
67
+
68
+ // ============================================================================
69
+ // Image Field (text input + Browse button)
70
+ // ============================================================================
71
+
72
+ export interface ImageFieldProps {
73
+ label: string
74
+ value: string | undefined
75
+ placeholder?: string
76
+ onChange: (value: string) => void
77
+ onBrowse: () => void
78
+ isDirty?: boolean
79
+ onReset?: () => void
80
+ }
81
+
82
+ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
83
+ return (
84
+ <div class="space-y-1.5">
85
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
86
+ <div class="flex gap-2">
87
+ <input
88
+ type="text"
89
+ value={value ?? ''}
90
+ placeholder={placeholder}
91
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
92
+ class={cn(
93
+ 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
94
+ isDirty
95
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
96
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
97
+ )}
98
+ data-cms-ui
99
+ />
100
+ <button
101
+ type="button"
102
+ onClick={onBrowse}
103
+ class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-md text-sm text-white transition-colors cursor-pointer"
104
+ data-cms-ui
105
+ >
106
+ Browse
107
+ </button>
108
+ </div>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ // ============================================================================
114
+ // Select Field (native select)
115
+ // ============================================================================
116
+
117
+ export interface SelectFieldProps {
118
+ label: string
119
+ value: string | undefined
120
+ options: Array<{ value: string; label: string }>
121
+ onChange: (value: string) => void
122
+ isDirty?: boolean
123
+ onReset?: () => void
124
+ allowEmpty?: boolean
125
+ }
126
+
127
+ export function SelectField({ label, value, options, onChange, isDirty, onReset, allowEmpty = true }: SelectFieldProps) {
128
+ return (
129
+ <div class="space-y-1.5">
130
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
131
+ <select
132
+ value={value ?? ''}
133
+ onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
134
+ class={cn(
135
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
136
+ isDirty
137
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
138
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
139
+ )}
140
+ data-cms-ui
141
+ >
142
+ {allowEmpty && <option value="">None</option>}
143
+ {options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
144
+ </select>
145
+ </div>
146
+ )
147
+ }
148
+
149
+ // ============================================================================
150
+ // Toggle Field
151
+ // ============================================================================
152
+
153
+ export interface ToggleFieldProps {
154
+ label: string
155
+ value: boolean | undefined
156
+ onChange: (value: boolean) => void
157
+ isDirty?: boolean
158
+ onReset?: () => void
159
+ }
160
+
161
+ export function ToggleField({ label, value, onChange, isDirty, onReset }: ToggleFieldProps) {
162
+ const isOn = value === true
163
+
164
+ const handleClick = useCallback((e: Event) => {
165
+ e.preventDefault()
166
+ e.stopPropagation()
167
+ onChange(!isOn)
168
+ }, [isOn, onChange])
169
+
170
+ return (
171
+ <div class="space-y-1.5">
172
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
173
+ <button
174
+ type="button"
175
+ onClick={handleClick}
176
+ class={cn(
177
+ 'w-9 h-5 rounded-full transition-colors relative cursor-pointer flex-shrink-0',
178
+ isOn ? 'bg-cms-primary' : 'bg-white/20',
179
+ )}
180
+ data-cms-ui
181
+ >
182
+ <span
183
+ class={cn(
184
+ 'absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm pointer-events-none',
185
+ isOn && 'translate-x-4',
186
+ )}
187
+ />
188
+ </button>
189
+ </div>
190
+ )
191
+ }
192
+
193
+ // ============================================================================
194
+ // Number Field
195
+ // ============================================================================
196
+
197
+ export interface NumberFieldProps {
198
+ label: string
199
+ value: number | undefined
200
+ placeholder?: string
201
+ min?: number
202
+ max?: number
203
+ onChange: (value: number | undefined) => void
204
+ isDirty?: boolean
205
+ onReset?: () => void
206
+ }
207
+
208
+ export function NumberField({ label, value, placeholder, min, max, onChange, isDirty, onReset }: NumberFieldProps) {
209
+ return (
210
+ <div class="space-y-1.5">
211
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
212
+ <input
213
+ type="number"
214
+ value={value ?? ''}
215
+ placeholder={placeholder}
216
+ min={min}
217
+ max={max}
218
+ onInput={(e) => {
219
+ const val = (e.target as HTMLInputElement).value
220
+ onChange(val === '' ? undefined : Number(val))
221
+ }}
222
+ class={cn(
223
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
224
+ isDirty
225
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
226
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
227
+ )}
228
+ data-cms-ui
229
+ />
230
+ </div>
231
+ )
232
+ }
233
+
234
+ // ============================================================================
235
+ // Highlight Match (helper for ComboBoxField)
236
+ // ============================================================================
237
+
238
+ export function HighlightMatch({ text, query }: { text: string; query: string }) {
239
+ if (!query) return <>{text}</>
240
+ const idx = text.toLowerCase().indexOf(query.toLowerCase())
241
+ if (idx === -1) return <>{text}</>
242
+ return (
243
+ <>
244
+ {text.slice(0, idx)}
245
+ <span class="text-cms-primary font-semibold">{text.slice(idx, idx + query.length)}</span>
246
+ {text.slice(idx + query.length)}
247
+ </>
248
+ )
249
+ }
250
+
251
+ // ============================================================================
252
+ // ComboBox Field (searchable dropdown with free-text input)
253
+ // ============================================================================
254
+
255
+ export interface ComboBoxFieldProps {
256
+ label: string
257
+ value: string | undefined
258
+ placeholder?: string
259
+ options: Array<{ value: string; label: string; description?: string }>
260
+ onChange: (value: string) => void
261
+ isDirty?: boolean
262
+ onReset?: () => void
263
+ }
264
+
265
+ export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
266
+ const [query, setQuery] = useState('')
267
+ const [isOpen, setIsOpen] = useState(false)
268
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
269
+ const inputRef = useRef<HTMLInputElement>(null)
270
+ const listRef = useRef<HTMLDivElement>(null)
271
+
272
+ // Filter options based on query
273
+ const filtered = useMemo(() => {
274
+ if (!query) return options
275
+ const q = query.toLowerCase()
276
+ return options.filter(
277
+ o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
278
+ )
279
+ }, [query, options])
280
+
281
+ const handleInput = useCallback((e: Event) => {
282
+ const v = (e.target as HTMLInputElement).value
283
+ setQuery(v)
284
+ onChange(v)
285
+ setIsOpen(true)
286
+ setHighlightedIndex(-1)
287
+ }, [onChange])
288
+
289
+ const handleFocus = useCallback(() => {
290
+ setIsOpen(true)
291
+ }, [])
292
+
293
+ const handleBlur = useCallback(() => {
294
+ // Delay to allow click on option to register
295
+ setTimeout(() => setIsOpen(false), 150)
296
+ }, [])
297
+
298
+ const selectOption = useCallback((optValue: string) => {
299
+ onChange(optValue)
300
+ setQuery('')
301
+ setIsOpen(false)
302
+ }, [onChange])
303
+
304
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
305
+ if (!isOpen || filtered.length === 0) return
306
+ if (e.key === 'ArrowDown') {
307
+ e.preventDefault()
308
+ setHighlightedIndex(i => Math.min(i + 1, filtered.length - 1))
309
+ } else if (e.key === 'ArrowUp') {
310
+ e.preventDefault()
311
+ setHighlightedIndex(i => Math.max(i - 1, 0))
312
+ } else if (e.key === 'Enter' && highlightedIndex >= 0) {
313
+ e.preventDefault()
314
+ selectOption(filtered[highlightedIndex]!.value)
315
+ } else if (e.key === 'Escape') {
316
+ setIsOpen(false)
317
+ }
318
+ }, [isOpen, filtered, highlightedIndex, selectOption])
319
+
320
+ // Scroll highlighted item into view
321
+ useEffect(() => {
322
+ if (highlightedIndex >= 0 && listRef.current) {
323
+ const item = listRef.current.children[highlightedIndex] as HTMLElement | undefined
324
+ item?.scrollIntoView({ block: 'nearest' })
325
+ }
326
+ }, [highlightedIndex])
327
+
328
+ const showDropdown = isOpen && filtered.length > 0
329
+
330
+ return (
331
+ <div class="space-y-1.5 relative">
332
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
333
+ <input
334
+ ref={inputRef}
335
+ type="text"
336
+ value={value ?? ''}
337
+ placeholder={placeholder}
338
+ onInput={handleInput}
339
+ onFocus={handleFocus}
340
+ onBlur={handleBlur}
341
+ onKeyDown={handleKeyDown}
342
+ autocomplete="off"
343
+ class={cn(
344
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
345
+ isDirty
346
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
347
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
348
+ )}
349
+ data-cms-ui
350
+ />
351
+ {showDropdown && (
352
+ <div
353
+ ref={listRef}
354
+ class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-md shadow-lg"
355
+ data-cms-ui
356
+ >
357
+ {filtered.map((opt, i) => (
358
+ <button
359
+ key={opt.value}
360
+ type="button"
361
+ onMouseDown={(e) => {
362
+ e.preventDefault()
363
+ selectOption(opt.value)
364
+ }}
365
+ class={cn(
366
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
367
+ i === highlightedIndex
368
+ ? 'bg-white/15 text-white'
369
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
370
+ )}
371
+ data-cms-ui
372
+ >
373
+ <span class="block truncate font-medium">
374
+ <HighlightMatch text={opt.label} query={query} />
375
+ </span>
376
+ {opt.description && (
377
+ <span class="block truncate text-white/40">
378
+ <HighlightMatch text={opt.description} query={query} />
379
+ </span>
380
+ )}
381
+ </button>
382
+ ))}
383
+ </div>
384
+ )}
385
+ </div>
386
+ )
387
+ }