@payload-onpage/react 0.0.0-stage → 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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/OnPageClient.d.ts +18 -0
  4. package/dist/OnPageClient.d.ts.map +1 -0
  5. package/dist/OnPageClient.js +51 -0
  6. package/dist/OnPageClient.js.map +1 -0
  7. package/dist/annotations.d.ts +55 -0
  8. package/dist/annotations.d.ts.map +1 -0
  9. package/dist/annotations.js +60 -0
  10. package/dist/annotations.js.map +1 -0
  11. package/dist/context.d.ts +7 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +19 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/dom.d.ts +42 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +95 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/editor/FieldHost.d.ts +5 -0
  20. package/dist/editor/FieldHost.d.ts.map +1 -0
  21. package/dist/editor/FieldHost.js +36 -0
  22. package/dist/editor/FieldHost.js.map +1 -0
  23. package/dist/editor/Ghosts.d.ts +6 -0
  24. package/dist/editor/Ghosts.d.ts.map +1 -0
  25. package/dist/editor/Ghosts.js +37 -0
  26. package/dist/editor/Ghosts.js.map +1 -0
  27. package/dist/editor/OnPageEditor.d.ts +25 -0
  28. package/dist/editor/OnPageEditor.d.ts.map +1 -0
  29. package/dist/editor/OnPageEditor.js +211 -0
  30. package/dist/editor/OnPageEditor.js.map +1 -0
  31. package/dist/editor/useGhosts.d.ts +24 -0
  32. package/dist/editor/useGhosts.d.ts.map +1 -0
  33. package/dist/editor/useGhosts.js +34 -0
  34. package/dist/editor/useGhosts.js.map +1 -0
  35. package/dist/editor/usePageEvents.d.ts +3 -0
  36. package/dist/editor/usePageEvents.d.ts.map +1 -0
  37. package/dist/editor/usePageEvents.js +164 -0
  38. package/dist/editor/usePageEvents.js.map +1 -0
  39. package/dist/editor-kit.d.ts +19 -0
  40. package/dist/editor-kit.d.ts.map +1 -0
  41. package/dist/editor-kit.js +17 -0
  42. package/dist/editor-kit.js.map +1 -0
  43. package/dist/editors/CheckboxEditor.d.ts +3 -0
  44. package/dist/editors/CheckboxEditor.d.ts.map +1 -0
  45. package/dist/editors/CheckboxEditor.js +10 -0
  46. package/dist/editors/CheckboxEditor.js.map +1 -0
  47. package/dist/editors/ColorEditor.d.ts +4 -0
  48. package/dist/editors/ColorEditor.d.ts.map +1 -0
  49. package/dist/editors/ColorEditor.js +21 -0
  50. package/dist/editors/ColorEditor.js.map +1 -0
  51. package/dist/editors/DateEditor.d.ts +3 -0
  52. package/dist/editors/DateEditor.d.ts.map +1 -0
  53. package/dist/editors/DateEditor.js +28 -0
  54. package/dist/editors/DateEditor.js.map +1 -0
  55. package/dist/editors/InlineText.d.ts +4 -0
  56. package/dist/editors/InlineText.d.ts.map +1 -0
  57. package/dist/editors/InlineText.js +77 -0
  58. package/dist/editors/InlineText.js.map +1 -0
  59. package/dist/editors/NumberEditor.d.ts +4 -0
  60. package/dist/editors/NumberEditor.d.ts.map +1 -0
  61. package/dist/editors/NumberEditor.js +23 -0
  62. package/dist/editors/NumberEditor.js.map +1 -0
  63. package/dist/editors/RelationshipEditor.d.ts +3 -0
  64. package/dist/editors/RelationshipEditor.d.ts.map +1 -0
  65. package/dist/editors/RelationshipEditor.js +60 -0
  66. package/dist/editors/RelationshipEditor.js.map +1 -0
  67. package/dist/editors/SelectEditor.d.ts +4 -0
  68. package/dist/editors/SelectEditor.d.ts.map +1 -0
  69. package/dist/editors/SelectEditor.js +36 -0
  70. package/dist/editors/SelectEditor.js.map +1 -0
  71. package/dist/editors/UploadEditor.d.ts +3 -0
  72. package/dist/editors/UploadEditor.d.ts.map +1 -0
  73. package/dist/editors/UploadEditor.js +48 -0
  74. package/dist/editors/UploadEditor.js.map +1 -0
  75. package/dist/editors/index.d.ts +13 -0
  76. package/dist/editors/index.d.ts.map +1 -0
  77. package/dist/editors/index.js +23 -0
  78. package/dist/editors/index.js.map +1 -0
  79. package/dist/editors/useDebouncedSearch.d.ts +17 -0
  80. package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
  81. package/dist/editors/useDebouncedSearch.js +50 -0
  82. package/dist/editors/useDebouncedSearch.js.map +1 -0
  83. package/dist/hub.d.ts +14 -0
  84. package/dist/hub.d.ts.map +1 -0
  85. package/dist/hub.js +20 -0
  86. package/dist/hub.js.map +1 -0
  87. package/dist/index.d.ts +20 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/layout.d.ts +12 -0
  92. package/dist/layout.d.ts.map +1 -0
  93. package/dist/layout.js +83 -0
  94. package/dist/layout.js.map +1 -0
  95. package/dist/locators.d.ts +19 -0
  96. package/dist/locators.d.ts.map +1 -0
  97. package/dist/locators.js +215 -0
  98. package/dist/locators.js.map +1 -0
  99. package/dist/messages.d.ts +100 -0
  100. package/dist/messages.d.ts.map +1 -0
  101. package/dist/messages.js +112 -0
  102. package/dist/messages.js.map +1 -0
  103. package/dist/registry.d.ts +15 -0
  104. package/dist/registry.d.ts.map +1 -0
  105. package/dist/registry.js +29 -0
  106. package/dist/registry.js.map +1 -0
  107. package/dist/renderers.d.ts +7 -0
  108. package/dist/renderers.d.ts.map +1 -0
  109. package/dist/renderers.js +12 -0
  110. package/dist/renderers.js.map +1 -0
  111. package/dist/sanitize.d.ts +2 -0
  112. package/dist/sanitize.d.ts.map +1 -0
  113. package/dist/sanitize.js +49 -0
  114. package/dist/sanitize.js.map +1 -0
  115. package/dist/styles.d.ts +8 -0
  116. package/dist/styles.d.ts.map +1 -0
  117. package/dist/styles.js +122 -0
  118. package/dist/styles.js.map +1 -0
  119. package/dist/targets.d.ts +18 -0
  120. package/dist/targets.d.ts.map +1 -0
  121. package/dist/targets.js +86 -0
  122. package/dist/targets.js.map +1 -0
  123. package/dist/types.d.ts +131 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/ui/BlockPicker.d.ts +3 -0
  128. package/dist/ui/BlockPicker.d.ts.map +1 -0
  129. package/dist/ui/BlockPicker.js +27 -0
  130. package/dist/ui/BlockPicker.js.map +1 -0
  131. package/dist/ui/Dialog.d.ts +22 -0
  132. package/dist/ui/Dialog.d.ts.map +1 -0
  133. package/dist/ui/Dialog.js +83 -0
  134. package/dist/ui/Dialog.js.map +1 -0
  135. package/dist/ui/Dock.d.ts +7 -0
  136. package/dist/ui/Dock.d.ts.map +1 -0
  137. package/dist/ui/Dock.js +104 -0
  138. package/dist/ui/Dock.js.map +1 -0
  139. package/dist/ui/Overlay.d.ts +2 -0
  140. package/dist/ui/Overlay.d.ts.map +1 -0
  141. package/dist/ui/Overlay.js +150 -0
  142. package/dist/ui/Overlay.js.map +1 -0
  143. package/dist/ui/PostButton.d.ts +10 -0
  144. package/dist/ui/PostButton.d.ts.map +1 -0
  145. package/dist/ui/PostButton.js +7 -0
  146. package/dist/ui/PostButton.js.map +1 -0
  147. package/dist/ui/PreviewToolbar.d.ts +15 -0
  148. package/dist/ui/PreviewToolbar.d.ts.map +1 -0
  149. package/dist/ui/PreviewToolbar.js +45 -0
  150. package/dist/ui/PreviewToolbar.js.map +1 -0
  151. package/dist/ui/SettingsPanel.d.ts +11 -0
  152. package/dist/ui/SettingsPanel.d.ts.map +1 -0
  153. package/dist/ui/SettingsPanel.js +130 -0
  154. package/dist/ui/SettingsPanel.js.map +1 -0
  155. package/dist/useEvent.d.ts +7 -0
  156. package/dist/useEvent.d.ts.map +1 -0
  157. package/dist/useEvent.js +15 -0
  158. package/dist/useEvent.js.map +1 -0
  159. package/package.json +71 -4
  160. package/src/OnPageClient.tsx +67 -0
  161. package/src/annotations.ts +83 -0
  162. package/src/context.tsx +23 -0
  163. package/src/dom.ts +118 -0
  164. package/src/editor/FieldHost.tsx +66 -0
  165. package/src/editor/Ghosts.tsx +55 -0
  166. package/src/editor/OnPageEditor.tsx +315 -0
  167. package/src/editor/useGhosts.ts +64 -0
  168. package/src/editor/usePageEvents.ts +161 -0
  169. package/src/editor-kit.ts +27 -0
  170. package/src/editors/CheckboxEditor.tsx +31 -0
  171. package/src/editors/ColorEditor.tsx +64 -0
  172. package/src/editors/DateEditor.tsx +64 -0
  173. package/src/editors/InlineText.tsx +106 -0
  174. package/src/editors/NumberEditor.tsx +69 -0
  175. package/src/editors/RelationshipEditor.tsx +153 -0
  176. package/src/editors/SelectEditor.tsx +74 -0
  177. package/src/editors/UploadEditor.tsx +130 -0
  178. package/src/editors/index.ts +35 -0
  179. package/src/editors/useDebouncedSearch.ts +54 -0
  180. package/src/hub.ts +32 -0
  181. package/src/index.ts +55 -0
  182. package/src/layout.ts +83 -0
  183. package/src/locators.ts +210 -0
  184. package/src/messages.ts +120 -0
  185. package/src/registry.ts +43 -0
  186. package/src/renderers.ts +15 -0
  187. package/src/sanitize.ts +52 -0
  188. package/src/styles.ts +122 -0
  189. package/src/targets.ts +101 -0
  190. package/src/types.ts +132 -0
  191. package/src/ui/BlockPicker.tsx +49 -0
  192. package/src/ui/Dialog.tsx +159 -0
  193. package/src/ui/Dock.tsx +240 -0
  194. package/src/ui/Overlay.tsx +324 -0
  195. package/src/ui/PostButton.tsx +26 -0
  196. package/src/ui/PreviewToolbar.tsx +123 -0
  197. package/src/ui/SettingsPanel.tsx +279 -0
  198. package/src/useEvent.ts +16 -0
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { FieldEditorProps } from '../types.ts'
5
+ import { Dialog } from '../ui/Dialog.tsx'
6
+
7
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
8
+
9
+ /** Hex color with a native picker. Use it from a text field: `admin.custom.onpage.editor: 'color'`. */
10
+ export function ColorEditor({
11
+ field,
12
+ value,
13
+ element,
14
+ commit,
15
+ preview,
16
+ close,
17
+ live,
18
+ t,
19
+ }: FieldEditorProps) {
20
+ const [draft, setDraft] = useState(typeof value === 'string' ? value : '')
21
+ const change = (v: string) => {
22
+ setDraft(v)
23
+ if (live && HEX.test(v)) preview(v)
24
+ }
25
+ const save = () => {
26
+ if (draft !== (value ?? '') && (draft === '' || HEX.test(draft))) commit(draft || null)
27
+ close()
28
+ }
29
+ return (
30
+ <Dialog
31
+ variant="popover"
32
+ anchor={element}
33
+ title={field.label}
34
+ onClose={close}
35
+ t={t}
36
+ width={260}
37
+ testId="onpage-color"
38
+ >
39
+ <div className="po-row po-inline-row">
40
+ <input
41
+ type="color"
42
+ aria-label={field.label}
43
+ value={HEX.test(draft) && draft.length === 7 ? draft : '#000000'}
44
+ onChange={(e) => change(e.target.value)}
45
+ data-testid="onpage-color-picker"
46
+ />
47
+ <input
48
+ className="po-input"
49
+ aria-label={field.label}
50
+ data-autofocus
51
+ value={draft}
52
+ placeholder="#1e40af"
53
+ aria-invalid={draft !== '' && !HEX.test(draft)}
54
+ onChange={(e) => change(e.target.value)}
55
+ onKeyDown={(e) => e.key === 'Enter' && save()}
56
+ data-testid="onpage-color-input"
57
+ />
58
+ </div>
59
+ <button type="button" className="po-btn po-primary" onClick={save} data-testid="onpage-apply">
60
+ {t('common.apply')}
61
+ </button>
62
+ </Dialog>
63
+ )
64
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { FieldEditorProps } from '../types.ts'
5
+ import { Dialog } from '../ui/Dialog.tsx'
6
+
7
+ /** Payload stores dates as ISO strings; the input works in local time. */
8
+ const toLocalInput = (iso: unknown, withTime: boolean): string => {
9
+ if (typeof iso !== 'string' || !iso) return ''
10
+ const d = new Date(iso)
11
+ if (Number.isNaN(d.getTime())) return ''
12
+ const pad = (n: number) => String(n).padStart(2, '0')
13
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
14
+ return withTime ? `${date}T${pad(d.getHours())}:${pad(d.getMinutes())}` : date
15
+ }
16
+
17
+ export function DateEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
18
+ const withTime = field.pickerAppearance === 'dayAndTime' || field.pickerAppearance === 'timeOnly'
19
+ const [draft, setDraft] = useState(toLocalInput(value, withTime))
20
+ const save = () => {
21
+ // Date-only values are stored at local noon, which survives any timezone shift (as Payload's picker does).
22
+ const next = draft ? new Date(withTime ? draft : `${draft}T12:00:00`).toISOString() : null
23
+ if (next !== value) commit(next)
24
+ close()
25
+ }
26
+ return (
27
+ <Dialog
28
+ variant="popover"
29
+ anchor={element}
30
+ title={field.label}
31
+ onClose={close}
32
+ t={t}
33
+ testId="onpage-date"
34
+ >
35
+ <div className="po-row">
36
+ <input
37
+ className="po-input"
38
+ type={withTime ? 'datetime-local' : 'date'}
39
+ aria-label={field.label}
40
+ data-autofocus
41
+ value={draft}
42
+ onChange={(e) => setDraft(e.target.value)}
43
+ onKeyDown={(e) => e.key === 'Enter' && save()}
44
+ data-testid="onpage-date-input"
45
+ />
46
+ </div>
47
+ <div className="po-actions">
48
+ <button
49
+ type="button"
50
+ className="po-btn po-primary"
51
+ onClick={save}
52
+ data-testid="onpage-apply"
53
+ >
54
+ {t('common.apply')}
55
+ </button>
56
+ {!field.required && (
57
+ <button type="button" className="po-btn" onClick={() => setDraft('')}>
58
+ {t('common.clear')}
59
+ </button>
60
+ )}
61
+ </div>
62
+ </Dialog>
63
+ )
64
+ }
@@ -0,0 +1,106 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * In-place editing for text, textarea and email fields.
5
+ *
6
+ * The site's element is never made editable (React owns its DOM). An editable
7
+ * twin with the same computed typography is laid over it and the original is
8
+ * hidden. In client mode each keystroke re-renders the page live underneath;
9
+ * in refresh mode the twin shows the text and, after commit, stays as a ghost
10
+ * until the server render arrives. Blur/Enter commits (one undo step), Escape
11
+ * cancels.
12
+ */
13
+ import { useLayoutEffect, useRef } from 'react'
14
+ import { toText } from '@payload-onpage/core'
15
+ import { copyTypography, HIDDEN, placeCaret, placeOver, typographyOf } from '../dom.ts'
16
+ import { useTrackElement } from '../layout.ts'
17
+ import type { FieldEditorProps } from '../types.ts'
18
+
19
+ export const escapeHtml = (s: string): string =>
20
+ s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
21
+
22
+ export function InlineText({
23
+ field,
24
+ value,
25
+ element,
26
+ commit,
27
+ preview,
28
+ close,
29
+ ghost,
30
+ live,
31
+ point,
32
+ t,
33
+ }: FieldEditorProps) {
34
+ const multiline = field.type === 'textarea'
35
+ const ref = useRef<HTMLDivElement>(null)
36
+ const initial = useRef(toText(value))
37
+ const done = useRef(false)
38
+
39
+ useLayoutEffect(() => {
40
+ const el = element()
41
+ const twin = ref.current
42
+ if (!twin) return
43
+ if (el) {
44
+ copyTypography(el, twin)
45
+ placeOver(twin, el, { minWidth: 40, minHeight: true })
46
+ el.setAttribute(HIDDEN, 'true')
47
+ }
48
+ twin.textContent = initial.current
49
+ twin.focus({ preventScroll: true })
50
+ placeCaret(twin, point)
51
+ return () => element()?.removeAttribute(HIDDEN)
52
+ // Runs once: the twin takes over the element for its lifetime.
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ }, [])
55
+
56
+ // Follow the element as the page reflows (client mode re-renders it live).
57
+ useTrackElement(element, (el) => {
58
+ if (!ref.current) return
59
+ if (!el.hasAttribute(HIDDEN)) el.setAttribute(HIDDEN, 'true')
60
+ placeOver(ref.current, el, { minWidth: 40, minHeight: true })
61
+ })
62
+
63
+ const read = () => {
64
+ // textContent, not innerText: innerText applies CSS (text-transform:
65
+ // uppercase on an eyebrow would save "SANDBOX" for "Sandbox").
66
+ const text = (ref.current?.textContent ?? '').replace(/\u00a0/g, ' ').replace(/\n$/, '')
67
+ return multiline ? text : text.replace(/\s*\n\s*/g, ' ')
68
+ }
69
+
70
+ const finish = (save: boolean) => {
71
+ if (done.current) return
72
+ done.current = true
73
+ const text = read()
74
+ if (save && text !== initial.current) {
75
+ commit(text === '' && !field.required ? null : text)
76
+ if (!live && ref.current) ghost({ html: escapeHtml(text), style: typographyOf(ref.current) })
77
+ }
78
+ close()
79
+ }
80
+
81
+ return (
82
+ <div
83
+ ref={ref}
84
+ className="po-inline"
85
+ contentEditable="plaintext-only"
86
+ suppressContentEditableWarning
87
+ role="textbox"
88
+ tabIndex={0}
89
+ aria-multiline={multiline}
90
+ aria-label={t('inline.label', { field: field.label })}
91
+ data-testid="onpage-inline-text"
92
+ spellCheck
93
+ onInput={() => live && preview(read())}
94
+ onBlur={() => finish(true)}
95
+ onKeyDown={(e) => {
96
+ if (e.key === 'Escape') {
97
+ e.preventDefault()
98
+ finish(false)
99
+ } else if (e.key === 'Enter' && (!multiline || e.metaKey || e.ctrlKey)) {
100
+ e.preventDefault()
101
+ finish(true)
102
+ }
103
+ }}
104
+ />
105
+ )
106
+ }
@@ -0,0 +1,69 @@
1
+ 'use client'
2
+
3
+ import { toText } from '@payload-onpage/core'
4
+ import { useState } from 'react'
5
+ import type { FieldEditorProps } from '../types.ts'
6
+ import { Dialog } from '../ui/Dialog.tsx'
7
+
8
+ /** Number input, with a slider when the field has min and max. */
9
+ export function NumberEditor({
10
+ field,
11
+ value,
12
+ element,
13
+ commit,
14
+ preview,
15
+ close,
16
+ live,
17
+ t,
18
+ }: FieldEditorProps) {
19
+ const [draft, setDraft] = useState(toText(value))
20
+ const slider = field.min !== undefined && field.max !== undefined
21
+ const save = () => {
22
+ const next = draft === '' ? null : Number(draft)
23
+ if (next !== value && (next === null || !Number.isNaN(next))) commit(next)
24
+ close()
25
+ }
26
+ const change = (v: string) => {
27
+ setDraft(v)
28
+ if (live && v !== '' && !Number.isNaN(Number(v))) preview(Number(v))
29
+ }
30
+ return (
31
+ <Dialog
32
+ variant="popover"
33
+ anchor={element}
34
+ title={field.label}
35
+ onClose={close}
36
+ t={t}
37
+ testId="onpage-number"
38
+ >
39
+ <div className="po-row">
40
+ {slider && (
41
+ <input
42
+ type="range"
43
+ aria-label={field.label}
44
+ min={field.min}
45
+ max={field.max}
46
+ value={draft || field.min}
47
+ onChange={(e) => change(e.target.value)}
48
+ data-testid="onpage-number-slider"
49
+ />
50
+ )}
51
+ <input
52
+ className="po-input"
53
+ type="number"
54
+ aria-label={field.label}
55
+ data-autofocus
56
+ min={field.min}
57
+ max={field.max}
58
+ value={draft}
59
+ onChange={(e) => change(e.target.value)}
60
+ onKeyDown={(e) => e.key === 'Enter' && save()}
61
+ data-testid="onpage-number-input"
62
+ />
63
+ </div>
64
+ <button type="button" className="po-btn po-primary" onClick={save} data-testid="onpage-apply">
65
+ {t('common.apply')}
66
+ </button>
67
+ </Dialog>
68
+ )
69
+ }
@@ -0,0 +1,153 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Relationship editor: search the related collection(s) by their title field
5
+ * (Payload REST, so access control applies) and pick one, or several for
6
+ * hasMany. Polymorphic fields get a collection switch and store `{ relationTo, value }`.
7
+ */
8
+ import { relationId, relationTitle, type SearchHit } from '@payload-onpage/core'
9
+ import { useCallback, useState } from 'react'
10
+ import { useOnPage } from '../context.tsx'
11
+ import type { FieldEditorProps } from '../types.ts'
12
+ import { Dialog } from '../ui/Dialog.tsx'
13
+ import { useDebouncedSearch } from './useDebouncedSearch.ts'
14
+
15
+ export function RelationshipEditor({
16
+ docKey,
17
+ field,
18
+ value,
19
+ element,
20
+ commit,
21
+ close,
22
+ t,
23
+ }: FieldEditorProps) {
24
+ const { session, snap } = useOnPage()
25
+ const targets = Array.isArray(field.relationTo)
26
+ ? field.relationTo
27
+ : field.relationTo
28
+ ? [field.relationTo]
29
+ : []
30
+ const poly = Array.isArray(field.relationTo)
31
+ const [collection, setCollection] = useState(targets[0] ?? '')
32
+ const relations = snap.docs[docKey]?.schema?.relations ?? {}
33
+ const titleField = relations[collection]?.titleField
34
+ const many = !!field.hasMany
35
+ const current: unknown[] = many
36
+ ? Array.isArray(value)
37
+ ? value
38
+ : []
39
+ : value == null
40
+ ? []
41
+ : [value]
42
+ const ds = session.dataSource
43
+ const search = useCallback(
44
+ async (query: string) =>
45
+ ds.search
46
+ ? {
47
+ items: await ds.search(collection, {
48
+ query,
49
+ titleField,
50
+ locale: session.locale,
51
+ limit: 12,
52
+ }),
53
+ hasMore: false,
54
+ }
55
+ : { items: [] as SearchHit[], hasMore: false },
56
+ [ds, collection, titleField, session.locale],
57
+ )
58
+ const results = useDebouncedSearch<SearchHit>(ds.search ? search : null, [])
59
+
60
+ const wrap = (hit: SearchHit): unknown =>
61
+ poly ? { relationTo: collection, value: hit.doc } : hit.doc
62
+ const same = (v: unknown, id: string | number) => String(relationId(v)) === String(id)
63
+ const pick = (hit: SearchHit) => {
64
+ if (!many) {
65
+ commit(wrap(hit))
66
+ close()
67
+ return
68
+ }
69
+ commit(
70
+ current.some((v) => same(v, hit.id))
71
+ ? current.filter((v) => !same(v, hit.id))
72
+ : [...current, wrap(hit)],
73
+ )
74
+ }
75
+ const remove = (i: number) => commit(many ? current.filter((_, j) => j !== i) : null)
76
+
77
+ return (
78
+ <Dialog
79
+ variant="popover"
80
+ anchor={element}
81
+ title={field.label}
82
+ onClose={close}
83
+ t={t}
84
+ width={320}
85
+ testId="onpage-relationship"
86
+ >
87
+ {current.map((v, i) => (
88
+ <div className="po-rel-item" key={`${String(relationId(v))}-${i}`}>
89
+ {relationTitle(v, titleField)}
90
+ {(!field.required || many) && (
91
+ <button
92
+ type="button"
93
+ onClick={() => remove(i)}
94
+ aria-label={`${t('common.remove')} ${relationTitle(v, titleField)}`}
95
+ >
96
+ ✕
97
+ </button>
98
+ )}
99
+ </div>
100
+ ))}
101
+ {poly && (
102
+ <div className="po-seg" role="radiogroup" aria-label={field.label}>
103
+ {targets.map((c) => (
104
+ <button
105
+ key={c}
106
+ type="button"
107
+ role="radio"
108
+ aria-checked={c === collection}
109
+ onClick={() => setCollection(c)}
110
+ >
111
+ {relations[c]?.label ?? c}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ )}
116
+ {!ds.search ? (
117
+ <p className="po-muted">{t('search.unsupported')}</p>
118
+ ) : (
119
+ <>
120
+ <input
121
+ className="po-input"
122
+ aria-label={t('search.placeholder')}
123
+ placeholder={t('search.placeholder')}
124
+ value={results.query}
125
+ data-autofocus
126
+ onChange={(e) => results.setQuery(e.target.value)}
127
+ data-testid="onpage-relationship-search"
128
+ />
129
+ {results.error && <p className="po-error">{results.error}</p>}
130
+ <div
131
+ className="po-pop-list"
132
+ role="listbox"
133
+ aria-multiselectable={many}
134
+ aria-label={field.label}
135
+ >
136
+ {results.items.map((h) => (
137
+ <button
138
+ key={String(h.id)}
139
+ type="button"
140
+ role="option"
141
+ aria-selected={current.some((v) => same(v, h.id))}
142
+ onClick={() => pick(h)}
143
+ data-testid={`onpage-relationship-${h.id}`}
144
+ >
145
+ {h.title}
146
+ </button>
147
+ ))}
148
+ </div>
149
+ </>
150
+ )}
151
+ </Dialog>
152
+ )
153
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { toText } from '@payload-onpage/core'
4
+ import type { FieldEditorProps } from '../types.ts'
5
+ import { Dialog } from '../ui/Dialog.tsx'
6
+
7
+ /** Select / radio: pick one (closes) or toggle several (hasMany). */
8
+ export function SelectEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
9
+ const options = field.options ?? []
10
+ const many = !!field.hasMany
11
+ const current: string[] = many
12
+ ? Array.isArray(value)
13
+ ? (value as string[])
14
+ : []
15
+ : value == null
16
+ ? []
17
+ : [toText(value)]
18
+ const pick = (v: string | null) => {
19
+ if (!many) {
20
+ commit(v)
21
+ close()
22
+ return
23
+ }
24
+ const set = new Set(current)
25
+ if (v === null) set.clear()
26
+ else if (set.has(v)) set.delete(v)
27
+ else set.add(v)
28
+ commit(options.map((o) => o.value).filter((o) => set.has(o)))
29
+ }
30
+ return (
31
+ <Dialog
32
+ variant="popover"
33
+ anchor={element}
34
+ title={field.label}
35
+ onClose={close}
36
+ t={t}
37
+ testId="onpage-select"
38
+ >
39
+ <div
40
+ className="po-pop-list"
41
+ role="listbox"
42
+ aria-multiselectable={many}
43
+ aria-label={field.label}
44
+ >
45
+ {!field.required && !many && (
46
+ <button
47
+ type="button"
48
+ role="option"
49
+ aria-selected={!current.length}
50
+ onClick={() => pick(null)}
51
+ >
52
+ {t('common.none')}
53
+ </button>
54
+ )}
55
+ {options.map((o) => {
56
+ const selected = current.includes(o.value)
57
+ return (
58
+ <button
59
+ key={o.value}
60
+ type="button"
61
+ role="option"
62
+ aria-selected={selected}
63
+ onClick={() => pick(o.value)}
64
+ data-testid={`onpage-option-${o.value}`}
65
+ >
66
+ {many ? (selected ? '☑ ' : '☐ ') : ''}
67
+ {o.label}
68
+ </button>
69
+ )
70
+ })}
71
+ </div>
72
+ </Dialog>
73
+ )
74
+ }
@@ -0,0 +1,130 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Image editor: pick from the upload collection (search, pages) or upload a
5
+ * new file, through the MediaLibrary port. The chosen document goes into state
6
+ * populated, so a client-rendered page shows it at once; saving sends its id.
7
+ */
8
+ import { relationId, type MediaItem } from '@payload-onpage/core'
9
+ import { useCallback, useRef, useState } from 'react'
10
+ import { useOnPage } from '../context.tsx'
11
+ import type { FieldEditorProps } from '../types.ts'
12
+ import { Dialog } from '../ui/Dialog.tsx'
13
+ import { useDebouncedSearch } from './useDebouncedSearch.ts'
14
+
15
+ export function UploadEditor({ field, value, commit, close, t }: FieldEditorProps) {
16
+ const { media } = useOnPage()
17
+ const relationTo =
18
+ (Array.isArray(field.relationTo) ? field.relationTo[0] : field.relationTo) ?? 'media'
19
+ const [uploading, setUploading] = useState(false)
20
+ const [uploadError, setUploadError] = useState<string | null>(null)
21
+ const fileRef = useRef<HTMLInputElement>(null)
22
+ const search = useCallback(
23
+ (query: string, page: number) =>
24
+ media
25
+ ? media.search({ query, page, relationTo })
26
+ : Promise.resolve({ items: [] as MediaItem[], hasMore: false }),
27
+ [media, relationTo],
28
+ )
29
+ const results = useDebouncedSearch<MediaItem>(media ? search : null, [])
30
+ const currentId = relationId(value)
31
+
32
+ const choose = (m: MediaItem) => {
33
+ commit(m.doc)
34
+ close()
35
+ }
36
+ const upload = async (file: File) => {
37
+ if (!media?.upload) return
38
+ setUploading(true)
39
+ try {
40
+ choose(await media.upload(file, { relationTo }))
41
+ } catch (e) {
42
+ setUploadError((e as Error).message)
43
+ } finally {
44
+ setUploading(false)
45
+ }
46
+ }
47
+
48
+ const actions = (
49
+ <>
50
+ <input
51
+ className="po-input po-search"
52
+ aria-label={t('media.search')}
53
+ placeholder={t('media.search')}
54
+ value={results.query}
55
+ data-autofocus
56
+ onChange={(e) => results.setQuery(e.target.value)}
57
+ data-testid="onpage-media-search"
58
+ />
59
+ {media?.upload && (
60
+ <>
61
+ <button
62
+ type="button"
63
+ className="po-btn"
64
+ onClick={() => fileRef.current?.click()}
65
+ disabled={uploading}
66
+ data-testid="onpage-media-upload"
67
+ >
68
+ {t('media.upload')}
69
+ </button>
70
+ <input
71
+ ref={fileRef}
72
+ type="file"
73
+ accept="image/*"
74
+ hidden
75
+ onChange={(e) => e.target.files?.[0] && void upload(e.target.files[0])}
76
+ />
77
+ </>
78
+ )}
79
+ {!field.required && currentId != null && (
80
+ <button
81
+ type="button"
82
+ className="po-btn"
83
+ onClick={() => {
84
+ commit(null)
85
+ close()
86
+ }}
87
+ data-testid="onpage-media-remove"
88
+ >
89
+ {t('common.remove')}
90
+ </button>
91
+ )}
92
+ </>
93
+ )
94
+
95
+ return (
96
+ <Dialog
97
+ title={field.label}
98
+ onClose={close}
99
+ t={t}
100
+ actions={actions}
101
+ testId="onpage-media-picker"
102
+ >
103
+ {(results.error ?? uploadError) && <p className="po-error">{results.error ?? uploadError}</p>}
104
+ <div className="po-grid">
105
+ {results.items.map((m) => (
106
+ <button
107
+ key={String(m.id)}
108
+ type="button"
109
+ className="po-tile"
110
+ onClick={() => choose(m)}
111
+ aria-current={String(m.id) === String(currentId)}
112
+ data-testid={`onpage-media-${m.id}`}
113
+ >
114
+ <img src={m.thumbnailUrl ?? m.url ?? ''} alt={m.alt ?? ''} loading="lazy" />
115
+ <span>{m.alt || m.filename}</span>
116
+ </button>
117
+ ))}
118
+ </div>
119
+ {!results.busy && !results.items.length && <p className="po-muted">{t('media.empty')}</p>}
120
+ {results.hasMore && (
121
+ <div className="po-center">
122
+ <button type="button" className="po-btn" onClick={results.more} disabled={results.busy}>
123
+ {t('media.more')}
124
+ </button>
125
+ </div>
126
+ )}
127
+ {(results.busy || uploading) && <p className="po-muted">{t('media.loading')}</p>}
128
+ </Dialog>
129
+ )
130
+ }
@@ -0,0 +1,35 @@
1
+ /** The default field editors. Spread, override or remove (`null`) entries via the `editors` option. */
2
+ import type { EditorRegistry } from '../types.ts'
3
+ import { CheckboxEditor } from './CheckboxEditor.tsx'
4
+ import { ColorEditor } from './ColorEditor.tsx'
5
+ import { DateEditor } from './DateEditor.tsx'
6
+ import { InlineText } from './InlineText.tsx'
7
+ import { NumberEditor } from './NumberEditor.tsx'
8
+ import { RelationshipEditor } from './RelationshipEditor.tsx'
9
+ import { SelectEditor } from './SelectEditor.tsx'
10
+ import { UploadEditor } from './UploadEditor.tsx'
11
+
12
+ export const defaultEditors: EditorRegistry = {
13
+ text: { mode: 'inline', component: InlineText },
14
+ textarea: { mode: 'inline', component: InlineText },
15
+ email: { mode: 'inline', component: InlineText },
16
+ select: { mode: 'popover', component: SelectEditor },
17
+ radio: { mode: 'popover', component: SelectEditor },
18
+ checkbox: { mode: 'popover', component: CheckboxEditor },
19
+ number: { mode: 'popover', component: NumberEditor },
20
+ date: { mode: 'popover', component: DateEditor },
21
+ relationship: { mode: 'popover', component: RelationshipEditor },
22
+ upload: { mode: 'popover', component: UploadEditor },
23
+ color: { mode: 'popover', component: ColorEditor },
24
+ }
25
+
26
+ export {
27
+ CheckboxEditor,
28
+ ColorEditor,
29
+ DateEditor,
30
+ InlineText,
31
+ NumberEditor,
32
+ RelationshipEditor,
33
+ SelectEditor,
34
+ UploadEditor,
35
+ }