@payload-onpage/react 0.2.1 → 0.3.1

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 (218) hide show
  1. package/README.md +6 -0
  2. package/dist/OnPageClient.d.ts.map +1 -1
  3. package/dist/OnPageClient.js +9 -5
  4. package/dist/OnPageClient.js.map +1 -1
  5. package/dist/annotations.d.ts +1 -1
  6. package/dist/annotations.d.ts.map +1 -1
  7. package/dist/context.d.ts +19 -5
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js +31 -7
  10. package/dist/context.js.map +1 -1
  11. package/dist/dom.d.ts +16 -12
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +16 -16
  14. package/dist/dom.js.map +1 -1
  15. package/dist/editor/FieldHost.d.ts.map +1 -1
  16. package/dist/editor/FieldHost.js +18 -11
  17. package/dist/editor/FieldHost.js.map +1 -1
  18. package/dist/editor/OnPageEditor.d.ts +15 -8
  19. package/dist/editor/OnPageEditor.d.ts.map +1 -1
  20. package/dist/editor/OnPageEditor.js +73 -103
  21. package/dist/editor/OnPageEditor.js.map +1 -1
  22. package/dist/editor/editTarget.d.ts +28 -0
  23. package/dist/editor/editTarget.d.ts.map +1 -0
  24. package/dist/editor/editTarget.js +20 -0
  25. package/dist/editor/editTarget.js.map +1 -0
  26. package/dist/editor/useClientDocs.d.ts +4 -0
  27. package/dist/editor/useClientDocs.d.ts.map +1 -0
  28. package/dist/editor/useClientDocs.js +16 -0
  29. package/dist/editor/useClientDocs.js.map +1 -0
  30. package/dist/editor/useEditorSession.d.ts +13 -0
  31. package/dist/editor/useEditorSession.d.ts.map +1 -0
  32. package/dist/editor/useEditorSession.js +30 -0
  33. package/dist/editor/useEditorSession.js.map +1 -0
  34. package/dist/editor/useFlash.d.ts +4 -0
  35. package/dist/editor/useFlash.d.ts.map +1 -0
  36. package/dist/editor/useFlash.js +16 -0
  37. package/dist/editor/useFlash.js.map +1 -0
  38. package/dist/editor/useGhosts.d.ts +5 -7
  39. package/dist/editor/useGhosts.d.ts.map +1 -1
  40. package/dist/editor/useGhosts.js.map +1 -1
  41. package/dist/editor/usePageEvents.d.ts.map +1 -1
  42. package/dist/editor/usePageEvents.js +16 -16
  43. package/dist/editor/usePageEvents.js.map +1 -1
  44. package/dist/editor-kit.d.ts +10 -6
  45. package/dist/editor-kit.d.ts.map +1 -1
  46. package/dist/editor-kit.js +8 -5
  47. package/dist/editor-kit.js.map +1 -1
  48. package/dist/editors/CheckboxEditor.d.ts +6 -1
  49. package/dist/editors/CheckboxEditor.d.ts.map +1 -1
  50. package/dist/editors/CheckboxEditor.js +6 -3
  51. package/dist/editors/CheckboxEditor.js.map +1 -1
  52. package/dist/editors/ColorEditor.d.ts +3 -1
  53. package/dist/editors/ColorEditor.d.ts.map +1 -1
  54. package/dist/editors/ColorEditor.js +9 -15
  55. package/dist/editors/ColorEditor.js.map +1 -1
  56. package/dist/editors/DateEditor.d.ts +3 -1
  57. package/dist/editors/DateEditor.d.ts.map +1 -1
  58. package/dist/editors/DateEditor.js +10 -12
  59. package/dist/editors/DateEditor.js.map +1 -1
  60. package/dist/editors/InlineText.d.ts +2 -2
  61. package/dist/editors/InlineText.d.ts.map +1 -1
  62. package/dist/editors/InlineText.js +2 -3
  63. package/dist/editors/InlineText.js.map +1 -1
  64. package/dist/editors/NumberEditor.d.ts +3 -1
  65. package/dist/editors/NumberEditor.d.ts.map +1 -1
  66. package/dist/editors/NumberEditor.js +9 -17
  67. package/dist/editors/NumberEditor.js.map +1 -1
  68. package/dist/editors/RelationshipEditor.d.ts +7 -1
  69. package/dist/editors/RelationshipEditor.d.ts.map +1 -1
  70. package/dist/editors/RelationshipEditor.js +10 -18
  71. package/dist/editors/RelationshipEditor.js.map +1 -1
  72. package/dist/editors/SelectEditor.d.ts +4 -1
  73. package/dist/editors/SelectEditor.d.ts.map +1 -1
  74. package/dist/editors/SelectEditor.js +8 -13
  75. package/dist/editors/SelectEditor.js.map +1 -1
  76. package/dist/editors/UploadEditor.d.ts +7 -1
  77. package/dist/editors/UploadEditor.d.ts.map +1 -1
  78. package/dist/editors/UploadEditor.js +5 -5
  79. package/dist/editors/UploadEditor.js.map +1 -1
  80. package/dist/editors/index.d.ts +0 -1
  81. package/dist/editors/index.d.ts.map +1 -1
  82. package/dist/editors/index.js +56 -16
  83. package/dist/editors/index.js.map +1 -1
  84. package/dist/editors/kit.d.ts +39 -0
  85. package/dist/editors/kit.d.ts.map +1 -0
  86. package/dist/editors/kit.js +44 -0
  87. package/dist/editors/kit.js.map +1 -0
  88. package/dist/editors/useDebouncedSearch.d.ts +10 -6
  89. package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
  90. package/dist/editors/useDebouncedSearch.js +11 -7
  91. package/dist/editors/useDebouncedSearch.js.map +1 -1
  92. package/dist/geometry.d.ts +49 -0
  93. package/dist/geometry.d.ts.map +1 -0
  94. package/dist/geometry.js +48 -0
  95. package/dist/geometry.js.map +1 -0
  96. package/dist/hub.d.ts +4 -4
  97. package/dist/hub.d.ts.map +1 -1
  98. package/dist/hub.js.map +1 -1
  99. package/dist/index.d.ts +3 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +2 -2
  102. package/dist/index.js.map +1 -1
  103. package/dist/layout.d.ts.map +1 -1
  104. package/dist/layout.js +4 -2
  105. package/dist/layout.js.map +1 -1
  106. package/dist/locators.d.ts +0 -2
  107. package/dist/locators.d.ts.map +1 -1
  108. package/dist/locators.js +10 -16
  109. package/dist/locators.js.map +1 -1
  110. package/dist/messages.d.ts +15 -2
  111. package/dist/messages.d.ts.map +1 -1
  112. package/dist/messages.js +5 -0
  113. package/dist/messages.js.map +1 -1
  114. package/dist/registry.d.ts +21 -2
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +31 -6
  117. package/dist/registry.js.map +1 -1
  118. package/dist/rowOps.d.ts +20 -0
  119. package/dist/rowOps.d.ts.map +1 -0
  120. package/dist/rowOps.js +48 -0
  121. package/dist/rowOps.js.map +1 -0
  122. package/dist/sanitize.d.ts +2 -0
  123. package/dist/sanitize.d.ts.map +1 -1
  124. package/dist/sanitize.js +3 -0
  125. package/dist/sanitize.js.map +1 -1
  126. package/dist/settings.d.ts +10 -0
  127. package/dist/settings.d.ts.map +1 -0
  128. package/dist/settings.js +30 -0
  129. package/dist/settings.js.map +1 -0
  130. package/dist/styles.d.ts +1 -5
  131. package/dist/styles.d.ts.map +1 -1
  132. package/dist/styles.js +5 -4
  133. package/dist/styles.js.map +1 -1
  134. package/dist/targets.d.ts +1 -1
  135. package/dist/targets.d.ts.map +1 -1
  136. package/dist/types.d.ts +71 -46
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/ui/BlockPicker.d.ts.map +1 -1
  139. package/dist/ui/BlockPicker.js +6 -5
  140. package/dist/ui/BlockPicker.js.map +1 -1
  141. package/dist/ui/Dialog.d.ts +0 -5
  142. package/dist/ui/Dialog.d.ts.map +1 -1
  143. package/dist/ui/Dialog.js +10 -6
  144. package/dist/ui/Dialog.js.map +1 -1
  145. package/dist/ui/Dock.d.ts +1 -6
  146. package/dist/ui/Dock.d.ts.map +1 -1
  147. package/dist/ui/Dock.js +46 -28
  148. package/dist/ui/Dock.js.map +1 -1
  149. package/dist/ui/Overlay.d.ts.map +1 -1
  150. package/dist/ui/Overlay.js +39 -37
  151. package/dist/ui/Overlay.js.map +1 -1
  152. package/dist/ui/PreviewToolbar.d.ts +3 -3
  153. package/dist/ui/PreviewToolbar.d.ts.map +1 -1
  154. package/dist/ui/PreviewToolbar.js +4 -6
  155. package/dist/ui/PreviewToolbar.js.map +1 -1
  156. package/dist/ui/SettingsPanel.d.ts +0 -9
  157. package/dist/ui/SettingsPanel.d.ts.map +1 -1
  158. package/dist/ui/SettingsPanel.js +36 -41
  159. package/dist/ui/SettingsPanel.js.map +1 -1
  160. package/dist/ui/ToolbarButton.d.ts +12 -0
  161. package/dist/ui/ToolbarButton.d.ts.map +1 -0
  162. package/dist/ui/ToolbarButton.js +7 -0
  163. package/dist/ui/ToolbarButton.js.map +1 -0
  164. package/dist/useEvent.d.ts +1 -1
  165. package/dist/useEvent.d.ts.map +1 -1
  166. package/dist/useEvent.js +3 -1
  167. package/dist/useEvent.js.map +1 -1
  168. package/dist/useIsClient.d.ts +5 -0
  169. package/dist/useIsClient.d.ts.map +1 -0
  170. package/dist/useIsClient.js +8 -0
  171. package/dist/useIsClient.js.map +1 -0
  172. package/package.json +15 -4
  173. package/src/OnPageClient.tsx +9 -5
  174. package/src/annotations.ts +1 -1
  175. package/src/context.tsx +53 -10
  176. package/src/dom.ts +17 -18
  177. package/src/editor/FieldHost.tsx +26 -11
  178. package/src/editor/OnPageEditor.tsx +103 -160
  179. package/src/editor/editTarget.ts +41 -0
  180. package/src/editor/useClientDocs.ts +19 -0
  181. package/src/editor/useEditorSession.ts +36 -0
  182. package/src/editor/useFlash.ts +18 -0
  183. package/src/editor/useGhosts.ts +5 -7
  184. package/src/editor/usePageEvents.ts +13 -14
  185. package/src/editor-kit.ts +10 -6
  186. package/src/editors/CheckboxEditor.tsx +11 -12
  187. package/src/editors/ColorEditor.tsx +15 -35
  188. package/src/editors/DateEditor.tsx +18 -37
  189. package/src/editors/InlineText.tsx +4 -5
  190. package/src/editors/NumberEditor.tsx +15 -36
  191. package/src/editors/RelationshipEditor.tsx +18 -36
  192. package/src/editors/SelectEditor.tsx +14 -22
  193. package/src/editors/UploadEditor.tsx +6 -6
  194. package/src/editors/index.ts +56 -16
  195. package/src/editors/kit.tsx +97 -0
  196. package/src/editors/useDebouncedSearch.ts +18 -10
  197. package/src/geometry.ts +62 -0
  198. package/src/hub.ts +5 -5
  199. package/src/index.ts +5 -2
  200. package/src/layout.ts +5 -2
  201. package/src/locators.ts +11 -14
  202. package/src/messages.ts +26 -5
  203. package/src/registry.ts +42 -8
  204. package/src/rowOps.ts +64 -0
  205. package/src/sanitize.ts +6 -0
  206. package/src/settings.ts +49 -0
  207. package/src/styles.ts +5 -4
  208. package/src/targets.ts +1 -1
  209. package/src/types.ts +78 -45
  210. package/src/ui/BlockPicker.tsx +6 -7
  211. package/src/ui/Dialog.tsx +9 -5
  212. package/src/ui/Dock.tsx +67 -50
  213. package/src/ui/Overlay.tsx +75 -102
  214. package/src/ui/PreviewToolbar.tsx +7 -15
  215. package/src/ui/SettingsPanel.tsx +84 -81
  216. package/src/ui/ToolbarButton.tsx +36 -0
  217. package/src/useEvent.ts +4 -2
  218. package/src/useIsClient.ts +15 -0
@@ -6,35 +6,34 @@
6
6
  * from the target elements whenever the layout changes.
7
7
  */
8
8
  import {
9
- cloneRow,
10
9
  fieldAtPath,
11
10
  getAt,
12
- insertItem,
13
11
  isEditablePath,
14
- moveItem,
12
+ isListField,
15
13
  newArrayRow,
16
14
  refKey,
17
- removeItem,
18
15
  resolveRow,
19
- updateArray,
20
- type Doc,
16
+ rowPrefixes,
21
17
  type RowInfo,
22
18
  type Target,
19
+ isReady,
20
+ type RefKey,
23
21
  } from '@payload-onpage/core'
24
22
  import { useEffect, useState } from 'react'
25
23
  import { useOnPage } from '../context.tsx'
26
24
  import { isOurs } from '../dom.ts'
25
+ import { belowList, GEOMETRY, inViewport, outlineBox, toolbarPosition } from '../geometry.ts'
27
26
  import { useLayoutTick } from '../layout.ts'
27
+ import { ToolbarButton } from './ToolbarButton.tsx'
28
28
  import { targetsAt } from '../targets.ts'
29
- import { hasSettings } from './SettingsPanel.tsx'
29
+ import { insertRow, rowOps } from '../rowOps.ts'
30
+ import { hasSettings } from '../settings.ts'
30
31
 
31
32
  interface Hover {
32
- field?: Target
33
+ field?: Target | undefined
33
34
  rows: Target[] // innermost first
34
35
  }
35
36
 
36
- const VIEW_PAD = 8
37
-
38
37
  function useHover(): Hover {
39
38
  const { targets } = useOnPage()
40
39
  const [hover, setHover] = useState<Hover>({ rows: [] })
@@ -70,14 +69,14 @@ function Outline({
70
69
  if (r.width === 0 && r.height === 0) return null
71
70
  return (
72
71
  <>
73
- <div
74
- className={`po-outline${variant ? ` is-${variant}` : ''}`}
75
- style={{ top: r.top - 2, left: r.left - 2, width: r.width + 4, height: r.height + 4 }}
76
- />
72
+ <div className={`po-outline${variant ? ` is-${variant}` : ''}`} style={outlineBox(r)} />
77
73
  {label && (
78
74
  <div
79
75
  className={`po-chip${variant === 'item' ? ' is-item' : ''}`}
80
- style={{ top: Math.max(r.top - 2, 22), left: r.left - 2 }}
76
+ style={{
77
+ top: Math.max(r.top - GEOMETRY.outline, GEOMETRY.chipMinTop),
78
+ left: r.left - GEOMETRY.outline,
79
+ }}
81
80
  >
82
81
  {label}
83
82
  </div>
@@ -87,7 +86,7 @@ function Outline({
87
86
  }
88
87
 
89
88
  interface ActiveRow {
90
- key: string
89
+ key: RefKey
91
90
  info: RowInfo
92
91
  el: Element
93
92
  }
@@ -96,94 +95,67 @@ function RowToolbar({ row, variant }: { row: ActiveRow; variant: 'block' | 'item
96
95
  const { session, setSelected, setPanel, selected, targets, t } = useOnPage()
97
96
  const { key, info, el } = row
98
97
  const r = el.getBoundingClientRect()
99
- if (r.bottom < 40 || r.top > window.innerHeight - 40) return null
100
- const top = Math.min(Math.max(r.top + VIEW_PAD, 60), r.bottom - 40)
101
- const right = Math.max(window.innerWidth - r.right + VIEW_PAD, VIEW_PAD)
102
- const { listPath, index, length, rowFields, path } = info
98
+ if (!inViewport(r, GEOMETRY.toolbar)) return null
99
+ const { path } = info
103
100
  const label = targets.find(key, path, ['item'])?.label || info.label
104
- const apply = (fn: (d: Doc) => Doc) => session.apply(key, fn)
105
- const max = info.listField.maxRows
106
- const min = info.listField.minRows ?? 0
107
-
101
+ const ops = rowOps(session, key, info)
108
102
  const move = (dir: -1 | 1) => {
109
- apply((d) => updateArray(d, listPath, (a) => moveItem(a, index, index + dir)))
110
- setSelected({ key, path: `${listPath}.${index + dir}` })
103
+ const next = ops.move(dir)
104
+ if (next) setSelected(next)
111
105
  }
112
106
  const duplicate = () => {
113
- apply((d) =>
114
- updateArray(d, listPath, (a) => insertItem(a, index + 1, cloneRow(a[index], rowFields))),
115
- )
116
- setSelected({ key, path: `${listPath}.${index + 1}` })
107
+ const next = ops.duplicate()
108
+ if (next) setSelected(next)
117
109
  }
118
110
  const remove = () => {
119
- apply((d) => updateArray(d, listPath, (a) => removeItem(a, index)))
120
- if (selected?.key === key && selected.path.startsWith(path)) setSelected(null)
111
+ if (ops.remove() && selected?.key === key && selected.path.startsWith(path)) setSelected(null)
121
112
  }
122
113
  return (
123
114
  <div
124
115
  className={`po-bar${variant === 'item' ? ' is-item' : ''}`}
125
- style={{ top, right }}
116
+ style={toolbarPosition(r)}
126
117
  role="toolbar"
127
118
  aria-label={t('row.toolbar', { label })}
128
119
  data-testid={`onpage-${variant}-toolbar`}
129
120
  >
130
121
  <span className="po-btn po-bar-label">{label}</span>
131
- <button
132
- type="button"
133
- className="po-btn"
134
- disabled={index === 0}
122
+ <ToolbarButton
123
+ label={t('row.moveUp')}
124
+ icon="↑"
125
+ disabled={!ops.canMove(-1)}
135
126
  onClick={() => move(-1)}
136
- aria-label={t('row.moveUp')}
137
- title={t('row.moveUp')}
138
- data-testid={`onpage-${variant}-up`}
139
- >
140
- ↑
141
- </button>
142
- <button
143
- type="button"
144
- className="po-btn"
145
- disabled={index >= length - 1}
127
+ testId={`onpage-${variant}-up`}
128
+ />
129
+ <ToolbarButton
130
+ label={t('row.moveDown')}
131
+ icon="↓"
132
+ disabled={!ops.canMove(1)}
146
133
  onClick={() => move(1)}
147
- aria-label={t('row.moveDown')}
148
- title={t('row.moveDown')}
149
- data-testid={`onpage-${variant}-down`}
150
- >
151
- ↓
152
- </button>
153
- <button
154
- type="button"
155
- className="po-btn"
156
- disabled={max !== undefined && length >= max}
134
+ testId={`onpage-${variant}-down`}
135
+ />
136
+ <ToolbarButton
137
+ label={t('row.duplicate')}
138
+ icon="⧉"
139
+ disabled={!ops.canDuplicate}
157
140
  onClick={duplicate}
158
- aria-label={t('row.duplicate')}
159
- title={t('row.duplicate')}
160
- data-testid={`onpage-${variant}-duplicate`}
161
- >
162
- ⧉
163
- </button>
164
- {hasSettings(rowFields, path, key, targets) && (
165
- <button
166
- type="button"
167
- className="po-btn"
141
+ testId={`onpage-${variant}-duplicate`}
142
+ />
143
+ {hasSettings(info.rowFields, path, key, targets) && (
144
+ <ToolbarButton
145
+ label={t('row.settings')}
146
+ icon="⚙"
168
147
  onClick={() => setPanel({ kind: 'settings', key, path, title: label })}
169
- aria-label={t('row.settings')}
170
- title={t('row.settings')}
171
- data-testid={`onpage-${variant}-settings`}
172
- >
173
- ⚙
174
- </button>
148
+ testId={`onpage-${variant}-settings`}
149
+ />
175
150
  )}
176
- <button
177
- type="button"
178
- className="po-btn is-danger"
179
- disabled={length <= min}
151
+ <ToolbarButton
152
+ label={t('row.delete')}
153
+ icon="✕"
154
+ danger
155
+ disabled={!ops.canRemove}
180
156
  onClick={remove}
181
- aria-label={t('row.delete')}
182
- title={t('row.delete')}
183
- data-testid={`onpage-${variant}-delete`}
184
- >
185
- ✕
186
- </button>
157
+ testId={`onpage-${variant}-delete`}
158
+ />
187
159
  </div>
188
160
  )
189
161
  }
@@ -201,7 +173,7 @@ function InsertPoints({ row }: { row: ActiveRow }) {
201
173
  onClick={() =>
202
174
  setPanel({ kind: 'blocks', key: row.key, listPath: row.info.listPath, index })
203
175
  }
204
- aria-label={`${t('row.addSection')} (${edge})`}
176
+ aria-label={t(edge === 'above' ? 'row.addSectionAbove' : 'row.addSectionBelow')}
205
177
  data-testid={`onpage-insert-${edge}`}
206
178
  >
207
179
  + {t('row.addSection')}
@@ -220,10 +192,11 @@ function AddItem({ list }: { list: Target }) {
220
192
  const { snap, session, setPanel, setSelected, t } = useOnPage()
221
193
  const key = refKey(list.ref)
222
194
  const state = snap.docs[key]
223
- if (!state?.schema || !state.doc || !isEditablePath(state.schema, list.path)) return null
195
+ if (!isReady(state) || !isEditablePath(state.schema, list.path)) return null
224
196
  const field = fieldAtPath(state.schema.fields, state.doc, list.path)?.field
225
- if (!field || (field.type !== 'array' && field.type !== 'blocks')) return null
226
- const length = (getAt<unknown[]>(state.doc, list.path) ?? []).length
197
+ if (!field || !isListField(field)) return null
198
+ const rows = getAt(state.doc, list.path)
199
+ const length = Array.isArray(rows) ? rows.length : 0
227
200
  const r = list.el.getBoundingClientRect()
228
201
  if (field.type === 'blocks') {
229
202
  // Sections get "+ Add section" above/below each one; this covers an empty list.
@@ -231,7 +204,10 @@ function AddItem({ list }: { list: Target }) {
231
204
  return (
232
205
  <div
233
206
  className="po-add"
234
- style={{ top: r.top + Math.min(r.height / 2, 160), left: r.left + r.width / 2 }}
207
+ style={{
208
+ top: r.top + Math.min(r.height / 2, GEOMETRY.emptyListOffset),
209
+ left: r.left + r.width / 2,
210
+ }}
235
211
  >
236
212
  <button
237
213
  type="button"
@@ -246,13 +222,10 @@ function AddItem({ list }: { list: Target }) {
246
222
  }
247
223
  if (field.maxRows !== undefined && length >= field.maxRows) return null
248
224
  // Below the list, or pinned to the bottom of the viewport while the list runs past it.
249
- if (r.bottom < 0 || r.top > window.innerHeight - 40) return null
250
- const y = Math.min(r.bottom + 14, window.innerHeight - 84)
225
+ if (!inViewport(r) || r.top > window.innerHeight - GEOMETRY.toolbar) return null
226
+ const y = belowList(r)
251
227
  const item = list.label || field.labels?.singular || field.label
252
- const add = () => {
253
- session.apply(key, (d) => updateArray(d, list.path, (a) => [...a, newArrayRow(field)]))
254
- setSelected({ key, path: `${list.path}.${length}` })
255
- }
228
+ const add = () => setSelected(insertRow(session, key, list.path, length, newArrayRow(field)))
256
229
  return (
257
230
  <div className="po-add is-item" style={{ top: y, left: r.left + r.width / 2 }}>
258
231
  <button type="button" className="po-btn" onClick={add} data-testid="onpage-add-item">
@@ -271,8 +244,7 @@ export function Overlay() {
271
244
  if (!target) return null
272
245
  const key = refKey(target.ref)
273
246
  const state = snap.docs[key]
274
- const info =
275
- state?.schema && state.doc ? resolveRow(state.schema, state.doc, target.path) : null
247
+ const info = isReady(state) ? resolveRow(state.schema, state.doc, target.path) : null
276
248
  return info ? { key, info, el: target.el } : null
277
249
  }
278
250
 
@@ -284,13 +256,13 @@ export function Overlay() {
284
256
  // Its section: the outermost enclosing row of a blocks field (may be itself).
285
257
  let section: ActiveRow | null = null
286
258
  if (active) {
287
- const segs = active.info.path.split('.')
288
- for (let i = 1; i < segs.length && !section; i++) {
289
- if (!/^\d+$/.test(segs[i])) continue
290
- const path = segs.slice(0, i + 1).join('.')
259
+ for (const path of [...rowPrefixes(active.info.path), active.info.path]) {
291
260
  const candidate =
292
261
  path === active.info.path ? active : rowFor(targets.find(active.key, path, ['item']))
293
- if (candidate?.info.isBlock) section = candidate
262
+ if (candidate?.info.isBlock) {
263
+ section = candidate
264
+ break
265
+ }
294
266
  }
295
267
  }
296
268
  const item = active && section?.info.path !== active.info.path ? active : null
@@ -302,7 +274,8 @@ export function Overlay() {
302
274
  : undefined
303
275
  const emptyLists = lists.filter((l) => {
304
276
  const doc = snap.docs[refKey(l.ref)]?.doc
305
- return doc && !(getAt<unknown[]>(doc, l.path) ?? []).length
277
+ const rows = doc ? getAt(doc, l.path) : undefined
278
+ return doc && !(Array.isArray(rows) && rows.length)
306
279
  })
307
280
  const hoverField = !editing ? hover.field : undefined
308
281
 
@@ -5,21 +5,21 @@
5
5
  * page in a framed viewport. Signed-in editors can switch back to editing;
6
6
  * people on a shared link can leave to the published page.
7
7
  */
8
- import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
8
+ import { useEffect, useMemo, useState } from 'react'
9
+ import { useEditorStyles, useIsClient } from '../useIsClient.ts'
9
10
  import { createPortal } from 'react-dom'
10
11
  import { UI } from '../dom.ts'
11
12
  import { createTranslate, type Messages } from '../messages.ts'
12
- import { injectStyles } from '../styles.ts'
13
13
  import { PostButton } from './PostButton.tsx'
14
14
 
15
15
  export interface PreviewToolbarProps {
16
16
  /** Endpoint that leaves preview (POST), back to the published page. */
17
17
  exitHref: string
18
18
  /** Endpoint that switches to editing (POST); omit for people who can't edit (shared links). */
19
- editHref?: string
19
+ editHref?: string | undefined
20
20
  /** Opened from a shared preview link. */
21
- shared?: boolean
22
- messages?: Messages
21
+ shared?: boolean | undefined
22
+ messages?: Messages | undefined
23
23
  }
24
24
 
25
25
  const DEVICES = [
@@ -34,19 +34,11 @@ export const inPreviewFrame = (): boolean =>
34
34
  typeof window !== 'undefined' &&
35
35
  (window.self !== window.top || new URLSearchParams(window.location.search).has(FRAME_PARAM))
36
36
 
37
- const noop = () => () => {}
38
- const useMounted = () =>
39
- useSyncExternalStore(
40
- noop,
41
- () => true,
42
- () => false,
43
- )
44
-
45
37
  export function PreviewToolbar({ exitHref, editHref, shared, messages }: PreviewToolbarProps) {
46
38
  const [device, setDevice] = useState<(typeof DEVICES)[number] | null>(null)
47
- const mounted = useMounted()
39
+ const mounted = useIsClient()
48
40
  const t = useMemo(() => createTranslate(messages), [messages])
49
- useEffect(() => injectStyles(), [])
41
+ useEditorStyles()
50
42
  useEffect(() => {
51
43
  if (!device) return
52
44
  const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setDevice(null)
@@ -6,40 +6,29 @@
6
6
  * spacing…), checkboxes, numbers, dates, relationships, URLs and other plain
7
7
  * text, images that aren't on the page. Changes apply immediately.
8
8
  */
9
- import { fieldAtPath, getAt, relationTitle, toText, type SchemaField } from '@payload-onpage/core'
9
+ import {
10
+ fieldAtPath,
11
+ getAt,
12
+ relationTitle,
13
+ rowFieldsOf,
14
+ toText,
15
+ isListField,
16
+ type ChoiceField,
17
+ type NumberField,
18
+ type ScalarField,
19
+ type SchemaField,
20
+ isReady,
21
+ type RefKey,
22
+ } from '@payload-onpage/core'
10
23
  import { useId, useRef, useState, type KeyboardEvent, type RefObject } from 'react'
11
24
  import { useOnPage } from '../context.tsx'
12
25
  import type { Translate } from '../messages.ts'
13
- import type { TargetIndex } from '../types.ts'
26
+ import { checkedOf } from '../editors/CheckboxEditor.tsx'
27
+ import { selectedValues } from '../editors/SelectEditor.tsx'
28
+ import { canEdit } from '../registry.ts'
29
+ import { settable } from '../settings.ts'
14
30
  import { Dialog } from './Dialog.tsx'
15
31
 
16
- const SKIP = new Set(['id', 'blockType', 'blockName'])
17
- const TEXTUAL = new Set(['text', 'textarea', 'email', 'number', 'date', 'relationship', 'upload'])
18
- const INPUT_TYPES = new Set(['text', 'textarea', 'email', 'number'])
19
-
20
- function settable(field: SchemaField, path: string, key: string, targets: TargetIndex): boolean {
21
- if (SKIP.has(field.name)) return false
22
- const full = `${path}.${field.name}`
23
- switch (field.type) {
24
- case 'select':
25
- case 'radio':
26
- case 'checkbox':
27
- return true
28
- case 'group':
29
- return (field.fields ?? []).some((f) => settable(f, full, key, targets))
30
- default:
31
- // Shown on the page? Edit it there.
32
- return TEXTUAL.has(field.type) && !targets.find(key, full)
33
- }
34
- }
35
-
36
- export const hasSettings = (
37
- fields: SchemaField[],
38
- path: string,
39
- key: string,
40
- targets: TargetIndex,
41
- ): boolean => fields.some((f) => settable(f, path, key, targets))
42
-
43
32
  const display = (v: unknown, t: Translate): string => {
44
33
  if (v == null || v === '') return t('common.none')
45
34
  if (Array.isArray(v))
@@ -53,9 +42,9 @@ function TextInput({
53
42
  field,
54
43
  id,
55
44
  }: {
56
- docKey: string
45
+ docKey: RefKey
57
46
  path: string
58
- field: SchemaField
47
+ field: ScalarField | NumberField
59
48
  id: string
60
49
  }) {
61
50
  const { session, snap } = useOnPage()
@@ -92,14 +81,15 @@ function Segmented({
92
81
  field,
93
82
  labelId,
94
83
  }: {
95
- docKey: string
84
+ docKey: RefKey
96
85
  path: string
97
- field: SchemaField
86
+ field: ChoiceField
98
87
  labelId: string
99
88
  }) {
100
89
  const { session, snap } = useOnPage()
101
- const options = field.options ?? []
102
- const current = getAt(snap.docs[docKey]?.doc, path) ?? field.defaultValue ?? ''
90
+ const options = field.options
91
+ const current =
92
+ selectedValues(getAt(snap.docs[docKey]?.doc, path) ?? field.defaultValue, field)[0] ?? ''
103
93
  const index = Math.max(
104
94
  0,
105
95
  options.findIndex((o) => o.value === current),
@@ -148,54 +138,17 @@ function Control({
148
138
  id,
149
139
  labelId,
150
140
  }: {
151
- docKey: string
141
+ docKey: RefKey
152
142
  path: string
153
143
  field: SchemaField
154
144
  anchor: RefObject<HTMLDivElement | null>
155
145
  id: string
156
146
  labelId: string
157
147
  }) {
158
- const { session, snap, edit, t } = useOnPage()
148
+ const { session, snap, edit, editors, t } = useOnPage()
159
149
  const value = getAt(snap.docs[docKey]?.doc, path)
160
150
  const set = (v: unknown) => session.setValue(docKey, path, v)
161
- if ((field.type === 'select' || field.type === 'radio') && !field.hasMany) {
162
- const options = field.options ?? []
163
- if (options.length <= 5)
164
- return <Segmented docKey={docKey} path={path} field={field} labelId={labelId} />
165
- return (
166
- <select
167
- id={id}
168
- className="po-select"
169
- value={toText(value ?? field.defaultValue)}
170
- onChange={(e) => set(e.target.value || null)}
171
- data-testid={`onpage-setting-${path}`}
172
- >
173
- {!field.required && <option value="">{t('common.none')}</option>}
174
- {options.map((o) => (
175
- <option key={o.value} value={o.value}>
176
- {o.label}
177
- </option>
178
- ))}
179
- </select>
180
- )
181
- }
182
- if (field.type === 'checkbox') {
183
- return (
184
- <label className="po-check">
185
- <input
186
- type="checkbox"
187
- checked={Boolean(value ?? field.defaultValue)}
188
- onChange={(e) => set(e.target.checked)}
189
- data-testid={`onpage-setting-${path}`}
190
- />
191
- {field.label}
192
- </label>
193
- )
194
- }
195
- if (INPUT_TYPES.has(field.type))
196
- return <TextInput docKey={docKey} path={path} field={field} id={id} />
197
- // Everything else opens its field editor, anchored to the panel row.
198
- return (
151
+ const openEditor = (
199
152
  <button
200
153
  id={id}
201
154
  type="button"
@@ -208,9 +161,59 @@ function Control({
208
161
  : display(value, t)}
209
162
  </button>
210
163
  )
164
+ // A field with its own editor (e.g. `editor: 'color'`) opens it, whatever its type.
165
+ if (field.editor && canEdit(editors[field.editor], field)) return openEditor
166
+ switch (field.type) {
167
+ case 'select':
168
+ case 'radio': {
169
+ if (field.hasMany) return openEditor
170
+ if (field.options.length <= 5)
171
+ return <Segmented docKey={docKey} path={path} field={field} labelId={labelId} />
172
+ return (
173
+ <select
174
+ id={id}
175
+ className="po-select"
176
+ value={selectedValues(value ?? field.defaultValue, field)[0] ?? ''}
177
+ onChange={(e) => set(e.target.value || null)}
178
+ data-testid={`onpage-setting-${path}`}
179
+ >
180
+ {!field.required && <option value="">{t('common.none')}</option>}
181
+ {field.options.map((o) => (
182
+ <option key={o.value} value={o.value}>
183
+ {o.label}
184
+ </option>
185
+ ))}
186
+ </select>
187
+ )
188
+ }
189
+ case 'checkbox':
190
+ return (
191
+ <label className="po-check">
192
+ <input
193
+ type="checkbox"
194
+ checked={checkedOf(value, field)}
195
+ onChange={(e) => set(e.target.checked)}
196
+ data-testid={`onpage-setting-${path}`}
197
+ />
198
+ {field.label}
199
+ </label>
200
+ )
201
+ case 'text':
202
+ case 'textarea':
203
+ case 'email':
204
+ case 'number':
205
+ return 'hasMany' in field && field.hasMany ? (
206
+ openEditor
207
+ ) : (
208
+ <TextInput docKey={docKey} path={path} field={field} id={id} />
209
+ )
210
+ default:
211
+ // Dates, relationships, images…: open the field editor, anchored to the panel row.
212
+ return openEditor
213
+ }
211
214
  }
212
215
 
213
- function Row({ docKey, path, field }: { docKey: string; path: string; field: SchemaField }) {
216
+ function Row({ docKey, path, field }: { docKey: RefKey; path: string; field: SchemaField }) {
214
217
  const anchor = useRef<HTMLDivElement>(null)
215
218
  const id = useId()
216
219
  const labelId = `${id}-label`
@@ -234,7 +237,7 @@ function Row({ docKey, path, field }: { docKey: string; path: string; field: Sch
234
237
  )
235
238
  }
236
239
 
237
- function Fields({ docKey, fields, path }: { docKey: string; fields: SchemaField[]; path: string }) {
240
+ function Fields({ docKey, fields, path }: { docKey: RefKey; fields: SchemaField[]; path: string }) {
238
241
  const { targets } = useOnPage()
239
242
  return (
240
243
  <>
@@ -246,7 +249,7 @@ function Fields({ docKey, fields, path }: { docKey: string; fields: SchemaField[
246
249
  return (
247
250
  <fieldset key={f.name} className="po-group">
248
251
  <legend className="po-h4">{f.label}</legend>
249
- <Fields docKey={docKey} fields={f.fields ?? []} path={full} />
252
+ <Fields docKey={docKey} fields={f.fields} path={full} />
250
253
  </fieldset>
251
254
  )
252
255
  }
@@ -260,11 +263,11 @@ export function SettingsPanel() {
260
263
  const { panel, setPanel, snap, t } = useOnPage()
261
264
  if (panel?.kind !== 'settings') return null
262
265
  const state = snap.docs[panel.key]
263
- if (!state?.schema || !state.doc) return null
264
- const owner = fieldAtPath(state.schema.fields, state.doc, panel.path)
266
+ if (!isReady(state)) return null
267
+ const owner = fieldAtPath(state.schema.fields, state.doc, panel.path)?.field
265
268
  // Row fields: the block's fields for a blocks row, the item fields for an array row.
266
269
  const fields =
267
- owner?.field.type === 'blocks' ? (owner.block?.fields ?? []) : (owner?.field.fields ?? [])
270
+ owner && isListField(owner) ? (rowFieldsOf(owner, getAt(state.doc, panel.path)) ?? []) : []
268
271
  return (
269
272
  <Dialog
270
273
  variant="panel"
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ /** An icon button in a toolbar: labelled for assistive tech, with the label (or a hint) as its tooltip. */
6
+ export function ToolbarButton({
7
+ label,
8
+ hint,
9
+ icon,
10
+ onClick,
11
+ disabled,
12
+ danger,
13
+ testId,
14
+ }: {
15
+ label: string
16
+ hint?: string
17
+ icon: ReactNode
18
+ onClick: () => void
19
+ disabled?: boolean
20
+ danger?: boolean
21
+ testId: string
22
+ }) {
23
+ return (
24
+ <button
25
+ type="button"
26
+ className={`po-btn${danger ? ' is-danger' : ''}`}
27
+ disabled={disabled}
28
+ onClick={onClick}
29
+ aria-label={label}
30
+ title={hint ?? label}
31
+ data-testid={testId}
32
+ >
33
+ {icon}
34
+ </button>
35
+ )
36
+ }
package/src/useEvent.ts CHANGED
@@ -7,10 +7,12 @@ import { useCallback, useInsertionEffect, useRef } from 'react'
7
7
  * pattern). Our own, because the React bundled with Next.js 15 doesn't export
8
8
  * `useEffectEvent`. Call it from effects and event handlers, not during render.
9
9
  */
10
- export function useEvent<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
10
+ export function useEvent<F extends (...args: never[]) => unknown>(fn: F): F {
11
11
  const latest = useRef(fn)
12
12
  useInsertionEffect(() => {
13
13
  latest.current = fn
14
14
  })
15
- return useCallback((...args: A) => latest.current(...args), [])
15
+ const stable = useCallback((...args: Parameters<F>) => latest.current(...args), [])
16
+ // The wrapper has F's exact signature: it forwards its arguments and result unchanged.
17
+ return stable as F
16
18
  }
@@ -0,0 +1,15 @@
1
+ import { useEffect, useSyncExternalStore } from 'react'
2
+ import { injectStyles } from './styles.ts'
3
+
4
+ const noopSubscribe = () => () => {}
5
+
6
+ /** False during SSR and hydration, true after: for UI that only exists in the browser. */
7
+ export const useIsClient = (): boolean =>
8
+ useSyncExternalStore(
9
+ noopSubscribe,
10
+ () => true,
11
+ () => false,
12
+ )
13
+
14
+ /** Adds the editor's stylesheet to the page while mounted. */
15
+ export const useEditorStyles = (): void => useEffect(() => injectStyles(), [])