@nuasite/cms 0.28.0 → 0.30.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 (37) hide show
  1. package/dist/editor.js +12447 -12473
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +69 -35
  4. package/src/dev-middleware.ts +86 -45
  5. package/src/editor/components/attribute-editor.tsx +2 -10
  6. package/src/editor/components/bg-image-overlay.tsx +2 -10
  7. package/src/editor/components/collections-browser.tsx +8 -24
  8. package/src/editor/components/color-toolbar.tsx +2 -9
  9. package/src/editor/components/confirm-dialog.tsx +4 -12
  10. package/src/editor/components/create-page-modal.tsx +23 -19
  11. package/src/editor/components/fields.tsx +158 -124
  12. package/src/editor/components/frontmatter-fields.tsx +9 -1
  13. package/src/editor/components/link-edit-popover.tsx +3 -6
  14. package/src/editor/components/markdown-editor-overlay.tsx +44 -46
  15. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  16. package/src/editor/components/mdx-block-view.tsx +1 -0
  17. package/src/editor/components/mdx-component-picker.tsx +3 -6
  18. package/src/editor/components/media-library.tsx +15 -37
  19. package/src/editor/components/modal-shell.tsx +34 -5
  20. package/src/editor/components/prop-editor.tsx +77 -73
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/toolbar.tsx +2 -1
  25. package/src/editor/constants.ts +33 -0
  26. package/src/editor/hooks/index.ts +4 -0
  27. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  28. package/src/editor/hooks/useSearchFilter.ts +21 -0
  29. package/src/field-types.ts +2 -0
  30. package/src/handlers/api-routes.ts +10 -16
  31. package/src/html-processor.ts +75 -94
  32. package/src/index.ts +5 -0
  33. package/src/manifest-writer.ts +15 -0
  34. package/src/rehype-cms-marker.ts +15 -0
  35. package/src/types.ts +1 -0
  36. package/src/vite-plugin.ts +18 -72
  37. package/src/content-invalidator.ts +0 -134
@@ -20,6 +20,7 @@ import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMark
20
20
  import { LinkEditPopover } from './link-edit-popover'
21
21
  import { MdxComponentIcon } from './mdx-block-view'
22
22
  import { MdxComponentPicker } from './mdx-component-picker'
23
+ import { Spinner } from './spinner'
23
24
 
24
25
  export interface MarkdownInlineEditorProps {
25
26
  elementId: string
@@ -649,7 +650,7 @@ export function MarkdownInlineEditor({
649
650
  {/* Loading state */}
650
651
  {!isReady && (
651
652
  <div class="absolute inset-0 flex items-center justify-center bg-cms-dark/80">
652
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-white/30 border-t-cms-primary" />
653
+ <Spinner size="lg" className="text-cms-primary" />
653
654
  </div>
654
655
  )}
655
656
  </div>
@@ -314,6 +314,7 @@ const INLINE_INPUT_TYPES: Record<string, string> = {
314
314
  datetime: 'datetime-local',
315
315
  time: 'time',
316
316
  email: 'email',
317
+ tel: 'tel',
317
318
  }
318
319
  const inputClass =
319
320
  'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
@@ -2,7 +2,7 @@ import { useState } from 'preact/hooks'
2
2
  import { getComponentDefinitions } from '../manifest'
3
3
  import { manifest, mdxComponentPickerOpen } from '../signals'
4
4
  import { ComponentCard, getDefaultProps } from './component-card'
5
- import { CancelButton, ModalBackdrop, ModalHeader } from './modal-shell'
5
+ import { CancelButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
6
6
  import { PropEditor } from './prop-editor'
7
7
 
8
8
  export interface MdxComponentPickerProps {
@@ -106,12 +106,9 @@ export function MdxComponentPicker({ onInsert }: MdxComponentPickerProps) {
106
106
  </div>
107
107
  <div class="px-5 py-4 border-t border-white/10 flex gap-2 justify-end">
108
108
  <CancelButton onClick={resetSelection} label="Back" />
109
- <button
110
- onClick={handleConfirmInsert}
111
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
112
- >
109
+ <PrimaryButton onClick={handleConfirmInsert} className="px-4">
113
110
  Insert
114
- </button>
111
+ </PrimaryButton>
115
112
  </div>
116
113
  </>
117
114
  )
@@ -1,8 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
4
  import { createMediaFolder, fetchMediaLibrary, fetchProjectImages, uploadMedia } from '../markdown-api'
4
5
  import { config, isMediaLibraryOpen, mediaLibraryState, resetMediaLibraryState, showToast } from '../signals'
5
6
  import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '../types'
7
+ import { CloseButton, PrimaryButton } from './modal-shell'
8
+ import { Spinner } from './spinner'
6
9
 
7
10
  const VECTOR_TYPES = new Set(['image/svg+xml', 'image/x-icon'])
8
11
 
@@ -187,18 +190,12 @@ export function MediaLibrary() {
187
190
  setShowNewFolderInput(false)
188
191
  }
189
192
 
190
- // Client-side filtering: both search query AND type filter
191
- const filteredItems = useMemo(() => {
192
- let items = allItems
193
- if (typeFilter !== 'all') {
194
- items = items.filter((item) => matchesTypeFilter(item.contentType, typeFilter))
195
- }
196
- if (searchQuery) {
197
- const q = searchQuery.toLowerCase()
198
- items = items.filter((item) => item.filename.toLowerCase().includes(q))
199
- }
200
- return items
201
- }, [searchQuery, typeFilter, allItems])
193
+ // Client-side filtering: type filter, then search query
194
+ const typeFiltered = useMemo(
195
+ () => typeFilter === 'all' ? allItems : allItems.filter(item => matchesTypeFilter(item.contentType, typeFilter)),
196
+ [typeFilter, allItems],
197
+ )
198
+ const filteredItems = useSearchFilter(typeFiltered, searchQuery, item => item.filename)
202
199
 
203
200
  // Build breadcrumb segments
204
201
  const breadcrumbs = useMemo(() => {
@@ -230,16 +227,7 @@ export function MediaLibrary() {
230
227
  {/* Header */}
231
228
  <div class="flex items-center justify-between p-5 border-b border-white/10">
232
229
  <h2 class="text-lg font-semibold text-white">Media Library</h2>
233
- <button
234
- type="button"
235
- onClick={handleClose}
236
- class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
237
- data-cms-ui
238
- >
239
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
240
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
241
- </svg>
242
- </button>
230
+ <CloseButton onClick={handleClose} />
243
231
  </div>
244
232
 
245
233
  {/* Breadcrumbs */}
@@ -307,14 +295,9 @@ export function MediaLibrary() {
307
295
  />
308
296
  </svg>
309
297
  </button>
310
- <button
311
- type="button"
312
- onClick={handleUploadClick}
313
- class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
314
- data-cms-ui
315
- >
298
+ <PrimaryButton onClick={handleUploadClick}>
316
299
  Upload
317
- </button>
300
+ </PrimaryButton>
318
301
  <input
319
302
  ref={fileInputRef}
320
303
  type="file"
@@ -372,14 +355,9 @@ export function MediaLibrary() {
372
355
  class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
373
356
  data-cms-ui
374
357
  />
375
- <button
376
- type="button"
377
- onClick={handleCreateFolder}
378
- class="px-3 py-1.5 bg-cms-primary text-cms-primary-text rounded-cms-md text-xs font-medium hover:bg-cms-primary-hover transition-colors"
379
- data-cms-ui
380
- >
358
+ <PrimaryButton onClick={handleCreateFolder} className="px-3 py-1.5 rounded-cms-md text-xs">
381
359
  Create
382
- </button>
360
+ </PrimaryButton>
383
361
  <button
384
362
  type="button"
385
363
  onClick={() => {
@@ -414,7 +392,7 @@ export function MediaLibrary() {
414
392
  {isLoading
415
393
  ? (
416
394
  <div class="flex items-center justify-center h-48">
417
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-cms-primary" />
395
+ <Spinner size="xl" className="text-cms-primary" />
418
396
  </div>
419
397
  )
420
398
  : folders.length === 0 && filteredItems.length === 0
@@ -1,5 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
2
  import { Z_INDEX } from '../constants'
3
+ import { cn } from '../lib/cn'
3
4
 
4
5
  export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, children }: {
5
6
  onClose: () => void
@@ -15,7 +16,7 @@ export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, chil
15
16
  data-cms-ui
16
17
  >
17
18
  <div
18
- class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] ${maxWidth} w-full border border-white/10 ${extraClass ?? ''}`}
19
+ class={cn('bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] w-full border border-white/10', maxWidth, extraClass)}
19
20
  onClick={(e) => e.stopPropagation()}
20
21
  data-cms-ui
21
22
  >
@@ -58,7 +59,7 @@ export function ModalFooter({ children }: { children: ComponentChildren }) {
58
59
  )
59
60
  }
60
61
 
61
- export function CloseButton({ onClick }: { onClick: () => void }) {
62
+ export function CloseButton({ onClick, size = 'md' }: { onClick: () => void; size?: 'sm' | 'md' }) {
62
63
  return (
63
64
  <button
64
65
  type="button"
@@ -66,22 +67,50 @@ export function CloseButton({ onClick }: { onClick: () => void }) {
66
67
  class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
67
68
  data-cms-ui
68
69
  >
69
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
+ <svg class={size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'} fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
71
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
71
72
  </svg>
72
73
  </button>
73
74
  )
74
75
  }
75
76
 
76
- export function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) {
77
+ export function CancelButton({ onClick, label = 'Cancel', className }: { onClick: () => void; label?: string; className?: string }) {
77
78
  return (
78
79
  <button
79
80
  type="button"
80
81
  onClick={onClick}
81
- class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
82
+ class={cn(
83
+ 'px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer',
84
+ className,
85
+ )}
82
86
  data-cms-ui
83
87
  >
84
88
  {label}
85
89
  </button>
86
90
  )
87
91
  }
92
+
93
+ export function PrimaryButton({ onClick, children, disabled, type = 'button', className }: {
94
+ onClick?: () => void
95
+ children: ComponentChildren
96
+ disabled?: boolean
97
+ type?: 'button' | 'submit'
98
+ className?: string
99
+ }) {
100
+ return (
101
+ <button
102
+ type={type}
103
+ onClick={onClick}
104
+ disabled={disabled}
105
+ class={cn(
106
+ 'px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer',
107
+ 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover',
108
+ 'disabled:opacity-40 disabled:cursor-not-allowed',
109
+ className,
110
+ )}
111
+ data-cms-ui
112
+ >
113
+ {children}
114
+ </button>
115
+ )
116
+ }
@@ -1,8 +1,10 @@
1
- import { useMemo, useRef, useState } from 'preact/hooks'
1
+ import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { slugify } from '../../shared'
3
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
4
  import { getCollectionEntryOptions } from '../manifest'
4
5
  import { manifest, openMediaLibraryWithCallback, pendingCollectionEntries } from '../signals'
5
6
  import type { ComponentProp } from '../types'
7
+ import { DropdownPanel } from './fields'
6
8
  import { SchemaFrontmatterField } from './frontmatter-fields'
7
9
 
8
10
  export interface PropEditorProps {
@@ -181,20 +183,14 @@ function ReferenceSelect({ collection, value, required, onChange }: {
181
183
  [collection, currentManifest],
182
184
  )
183
185
  const collectionDef = currentManifest?.collectionDefinitions?.[collection]
184
- const containerRef = useRef<HTMLDivElement>(null)
186
+ const inputRef = useRef<HTMLInputElement>(null)
185
187
  const [search, setSearch] = useState('')
186
188
  const [isOpen, setIsOpen] = useState(false)
187
189
  const [isCreating, setIsCreating] = useState(false)
188
190
  const [newName, setNewName] = useState('')
189
191
  const [formData, setFormData] = useState<Record<string, unknown>>({})
190
192
 
191
- const filtered = useMemo(
192
- () =>
193
- search
194
- ? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()) || o.value.toLowerCase().includes(search.toLowerCase()))
195
- : options,
196
- [options, search],
197
- )
193
+ const filtered = useSearchFilter(options, search, o => `${o.label} ${o.value}`)
198
194
 
199
195
  const selectedLabel = useMemo(
200
196
  () => value ? (options.find(o => o.value === value)?.label ?? value) : '',
@@ -206,6 +202,8 @@ function ReferenceSelect({ collection, value, required, onChange }: {
206
202
  [collectionDef],
207
203
  )
208
204
 
205
+ const closeDropdown = useCallback(() => setIsOpen(false), [])
206
+
209
207
  const resetCreateForm = () => {
210
208
  setIsCreating(false)
211
209
  setNewName('')
@@ -233,7 +231,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
233
231
  if (isCreating) {
234
232
  const slug = slugify(newName.trim())
235
233
  return (
236
- <div class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3">
234
+ <form
235
+ class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3"
236
+ onSubmit={(e) => {
237
+ e.preventDefault()
238
+ handleCreate()
239
+ }}
240
+ >
237
241
  <div class="flex items-center justify-between">
238
242
  <span class="text-[12px] font-medium text-white/70">Create new entry</span>
239
243
  {options.length > 0 && (
@@ -251,6 +255,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
251
255
  value={newName}
252
256
  onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
253
257
  placeholder="Enter name..."
258
+ required
254
259
  class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
255
260
  autoFocus
256
261
  />
@@ -279,15 +284,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
279
284
  Cancel
280
285
  </button>
281
286
  <button
282
- type="button"
283
- onClick={handleCreate}
284
- disabled={!newName.trim()}
287
+ type="submit"
285
288
  class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
286
289
  >
287
290
  Create
288
291
  </button>
289
292
  </div>
290
- </div>
293
+ </form>
291
294
  )
292
295
  }
293
296
 
@@ -304,8 +307,9 @@ function ReferenceSelect({ collection, value, required, onChange }: {
304
307
  }
305
308
 
306
309
  return (
307
- <div class="relative" ref={containerRef}>
310
+ <div>
308
311
  <input
312
+ ref={inputRef}
309
313
  type="text"
310
314
  value={isOpen ? search : selectedLabel}
311
315
  onInput={(e) => {
@@ -313,67 +317,67 @@ function ReferenceSelect({ collection, value, required, onChange }: {
313
317
  setIsOpen(true)
314
318
  }}
315
319
  onFocus={() => setIsOpen(true)}
316
- onBlur={(e) => {
317
- const related = (e as FocusEvent).relatedTarget as Node | null
318
- if (containerRef.current && related && containerRef.current.contains(related)) return
319
- setIsOpen(false)
320
- }}
320
+ onBlur={() => setTimeout(closeDropdown, 150)}
321
321
  placeholder={`Select ${collection} entry...`}
322
322
  class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
323
323
  />
324
- {isOpen && (
325
- <div class="absolute z-10 mt-1 w-full max-h-48 overflow-y-auto bg-cms-dark border border-white/20 rounded-cms-md shadow-lg">
326
- {!required && (
327
- <button
328
- type="button"
329
- onMouseDown={(e) => e.preventDefault()}
330
- onClick={() => {
331
- onChange('')
332
- setSearch('')
333
- setIsOpen(false)
334
- }}
335
- class="w-full px-4 py-2 text-left text-[13px] text-white/50 hover:bg-white/10 transition-colors"
336
- >
337
- — None —
338
- </button>
339
- )}
340
- {filtered.map((opt) => (
341
- <button
342
- key={opt.value}
343
- type="button"
344
- onMouseDown={(e) => e.preventDefault()}
345
- onClick={() => {
346
- onChange(opt.value)
347
- setSearch('')
348
- setIsOpen(false)
349
- }}
350
- class={`w-full px-4 py-2 text-left text-[13px] transition-colors ${
351
- opt.value === value ? 'bg-cms-primary/20 text-white' : 'text-white/80 hover:bg-white/10'
352
- }`}
353
- >
354
- <div>{opt.label}</div>
355
- {opt.label !== opt.value && <div class="text-[11px] text-white/40 font-mono">{opt.value}</div>}
356
- </button>
357
- ))}
358
- {filtered.length === 0 && <div class="px-4 py-2 text-[13px] text-white/40">No entries found</div>}
359
- {collectionDef && (
360
- <button
361
- type="button"
362
- onMouseDown={(e) => e.preventDefault()}
363
- onClick={() => {
364
- setIsCreating(true)
365
- setIsOpen(false)
366
- }}
367
- class="w-full px-4 py-2 text-left text-[13px] text-cms-primary hover:bg-cms-primary/10 transition-colors border-t border-white/10 flex items-center gap-2"
368
- >
369
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
370
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
371
- </svg>
372
- Create new {collectionDef.label?.toLowerCase() ?? collection}
373
- </button>
374
- )}
375
- </div>
376
- )}
324
+ <DropdownPanel
325
+ triggerRef={inputRef}
326
+ isOpen={isOpen}
327
+ onClose={closeDropdown}
328
+ maxHeight={192}
329
+ className="border border-white/20 rounded-cms-md"
330
+ >
331
+ {!required && (
332
+ <button
333
+ type="button"
334
+ onMouseDown={(e) => e.preventDefault()}
335
+ onClick={() => {
336
+ onChange('')
337
+ setSearch('')
338
+ setIsOpen(false)
339
+ }}
340
+ class="w-full px-4 py-2 text-left text-[13px] text-white/50 hover:bg-white/10 transition-colors"
341
+ >
342
+ — None —
343
+ </button>
344
+ )}
345
+ {filtered.map((opt) => (
346
+ <button
347
+ key={opt.value}
348
+ type="button"
349
+ onMouseDown={(e) => e.preventDefault()}
350
+ onClick={() => {
351
+ onChange(opt.value)
352
+ setSearch('')
353
+ setIsOpen(false)
354
+ }}
355
+ class={`w-full px-4 py-2 text-left text-[13px] transition-colors ${
356
+ opt.value === value ? 'bg-cms-primary/20 text-white' : 'text-white/80 hover:bg-white/10'
357
+ }`}
358
+ >
359
+ <div>{opt.label}</div>
360
+ {opt.label !== opt.value && <div class="text-[11px] text-white/40 font-mono">{opt.value}</div>}
361
+ </button>
362
+ ))}
363
+ {filtered.length === 0 && <div class="px-4 py-2 text-[13px] text-white/40">No entries found</div>}
364
+ {collectionDef && (
365
+ <button
366
+ type="button"
367
+ onMouseDown={(e) => e.preventDefault()}
368
+ onClick={() => {
369
+ setIsCreating(true)
370
+ setIsOpen(false)
371
+ }}
372
+ class="w-full px-4 py-2 text-left text-[13px] text-cms-primary hover:bg-cms-primary/10 transition-colors border-t border-white/10 flex items-center gap-2"
373
+ >
374
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
375
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
376
+ </svg>
377
+ Create new {collectionDef.label?.toLowerCase() ?? collection}
378
+ </button>
379
+ )}
380
+ </DropdownPanel>
377
381
  </div>
378
382
  )
379
383
  }
@@ -1,8 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { clampPanelPosition, Z_INDEX } from '../constants'
3
+ import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
4
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
5
  import { getCollectionEntryOptions } from '../manifest'
4
6
  import { updateMarkdownPage } from '../markdown-api'
5
7
  import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
8
+ import { Spinner } from './spinner'
6
9
 
7
10
  const PANEL_WIDTH = 320
8
11
 
@@ -27,11 +30,7 @@ export function ReferencePicker() {
27
30
  }
28
31
  }, [state.isOpen])
29
32
 
30
- const filtered = useMemo(() => {
31
- if (!query) return options
32
- const q = query.toLowerCase()
33
- return options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
34
- }, [query, options])
33
+ const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
35
34
 
36
35
  const currentLabel = useMemo(() => {
37
36
  if (state.isArray) return null
@@ -69,24 +68,7 @@ export function ReferencePicker() {
69
68
  updateReference([...current])
70
69
  }, [state.currentValues, updateReference])
71
70
 
72
- // Close on outside click or Escape
73
- useEffect(() => {
74
- if (!state.isOpen) return
75
- const onMouseDown = (e: MouseEvent) => {
76
- if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
77
- closeReferencePicker()
78
- }
79
- }
80
- const onKeyDown = (e: KeyboardEvent) => {
81
- if (e.key === 'Escape') closeReferencePicker()
82
- }
83
- document.addEventListener('mousedown', onMouseDown)
84
- document.addEventListener('keydown', onKeyDown)
85
- return () => {
86
- document.removeEventListener('mousedown', onMouseDown)
87
- document.removeEventListener('keydown', onKeyDown)
88
- }
89
- }, [state.isOpen])
71
+ useClickOutsideEscape([panelRef], state.isOpen, closeReferencePicker)
90
72
 
91
73
  if (!state.isOpen || !state.cursorPos) return null
92
74
 
@@ -113,7 +95,7 @@ export function ReferencePicker() {
113
95
  {saving
114
96
  ? (
115
97
  <div class="flex items-center justify-center gap-2 px-4 py-6">
116
- <span class="inline-block w-4 h-4 border-2 border-white/80 border-t-transparent rounded-full animate-spin" />
98
+ <Spinner className="text-white/80" />
117
99
  <span class="text-sm text-white/80">Updating...</span>
118
100
  </div>
119
101
  )
@@ -16,7 +16,8 @@ import {
16
16
  } from '../signals'
17
17
  import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
18
18
  import { ColorField, ComboBoxField, ImageField } from './fields'
19
- import { CloseButton, ModalBackdrop } from './modal-shell'
19
+ import { CancelButton, CloseButton, ModalBackdrop } from './modal-shell'
20
+ import { Spinner } from './spinner'
20
21
 
21
22
  const OG_TYPE_OPTIONS = [
22
23
  { value: 'website', label: 'Website', description: 'Default type for most pages' },
@@ -549,14 +550,7 @@ export function SeoEditor() {
549
550
  {/* Footer */}
550
551
  {hasSeoData && (
551
552
  <div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
552
- <button
553
- type="button"
554
- onClick={handleClose}
555
- class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
556
- data-cms-ui
557
- >
558
- Cancel
559
- </button>
553
+ <CancelButton onClick={handleClose} />
560
554
  <button
561
555
  type="button"
562
556
  onClick={handleSaveAll}
@@ -568,7 +562,7 @@ export function SeoEditor() {
568
562
  }`}
569
563
  data-cms-ui
570
564
  >
571
- {isSaving && <span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />}
565
+ {isSaving && <Spinner />}
572
566
  {isSaving ? 'Saving...' : 'Save Changes'}
573
567
  </button>
574
568
  </div>
@@ -0,0 +1,17 @@
1
+ import { cn } from '../lib/cn'
2
+
3
+ const sizes = {
4
+ xs: 'h-3 w-3',
5
+ sm: 'h-3.5 w-3.5',
6
+ md: 'h-4 w-4',
7
+ lg: 'h-6 w-6',
8
+ xl: 'h-8 w-8',
9
+ } as const
10
+
11
+ export function Spinner({ size = 'md', className }: { size?: keyof typeof sizes; className?: string }) {
12
+ return (
13
+ <span
14
+ class={cn('inline-block animate-spin rounded-full border-2 border-current/30 border-t-current', sizes[size], className)}
15
+ />
16
+ )
17
+ }
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
5
5
  import * as signals from '../signals'
6
6
  import { showConfirmDialog } from '../signals'
7
7
  import type { CollectionDefinition } from '../types'
8
+ import { Spinner } from './spinner'
8
9
 
9
10
  export interface ToolbarCallbacks {
10
11
  onEdit: () => void
@@ -229,7 +230,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
229
230
  {/* Saving indicator */}
230
231
  {isSaving && !showingOriginal && (
231
232
  <div class="flex items-center gap-1.5 px-3 py-2 sm:px-5 sm:py-2.5 text-sm font-medium text-white/80">
232
- <span class="inline-block w-3.5 h-3.5 border-2 border-white/80 border-t-transparent rounded-full animate-spin" />
233
+ <Spinner size="sm" className="text-white/80" />
233
234
  <span>Saving</span>
234
235
  </div>
235
236
  )}
@@ -136,3 +136,36 @@ export function clampPanelPosition(
136
136
  maxHeight: `${maxHeight}px`,
137
137
  }
138
138
  }
139
+
140
+ /**
141
+ * Calculate fixed-position style for a dropdown that needs to escape parent overflow clipping.
142
+ * Positions below the trigger element by default, flipping above when space is insufficient.
143
+ */
144
+ export function getDropdownPosition(
145
+ trigger: HTMLElement | null,
146
+ maxHeight: number,
147
+ padding = LAYOUT.VIEWPORT_PADDING,
148
+ ): Record<string, string> | undefined {
149
+ if (!trigger) return undefined
150
+ const rect = trigger.getBoundingClientRect()
151
+ const spaceBelow = window.innerHeight - rect.bottom - padding
152
+ const spaceAbove = rect.top - padding
153
+ const showAbove = spaceBelow < 80 && spaceAbove > spaceBelow
154
+
155
+ const style: Record<string, string> = {
156
+ position: 'fixed',
157
+ left: `${rect.left}px`,
158
+ width: `${rect.width}px`,
159
+ zIndex: String(Z_INDEX.MODAL),
160
+ }
161
+
162
+ if (showAbove) {
163
+ style.bottom = `${window.innerHeight - rect.top + 4}px`
164
+ style.maxHeight = `${Math.max(Math.min(maxHeight, spaceAbove), 80)}px`
165
+ } else {
166
+ style.top = `${rect.bottom + 4}px`
167
+ style.maxHeight = `${Math.max(Math.min(maxHeight, spaceBelow), 80)}px`
168
+ }
169
+
170
+ return style
171
+ }
@@ -17,3 +17,7 @@ export type { ImageHoverState } from './useImageHoverDetection'
17
17
 
18
18
  export { useBgImageHoverDetection } from './useBgImageHoverDetection'
19
19
  export type { BgImageHoverState } from './useBgImageHoverDetection'
20
+
21
+ export { useClickOutsideEscape } from './useClickOutsideEscape'
22
+
23
+ export { useSearchFilter } from './useSearchFilter'