@payload-onpage/react 0.2.1 → 0.3.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 (217) hide show
  1. package/dist/OnPageClient.d.ts.map +1 -1
  2. package/dist/OnPageClient.js +9 -5
  3. package/dist/OnPageClient.js.map +1 -1
  4. package/dist/annotations.d.ts +1 -1
  5. package/dist/annotations.d.ts.map +1 -1
  6. package/dist/context.d.ts +19 -5
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +31 -7
  9. package/dist/context.js.map +1 -1
  10. package/dist/dom.d.ts +16 -12
  11. package/dist/dom.d.ts.map +1 -1
  12. package/dist/dom.js +16 -16
  13. package/dist/dom.js.map +1 -1
  14. package/dist/editor/FieldHost.d.ts.map +1 -1
  15. package/dist/editor/FieldHost.js +18 -11
  16. package/dist/editor/FieldHost.js.map +1 -1
  17. package/dist/editor/OnPageEditor.d.ts +15 -8
  18. package/dist/editor/OnPageEditor.d.ts.map +1 -1
  19. package/dist/editor/OnPageEditor.js +73 -103
  20. package/dist/editor/OnPageEditor.js.map +1 -1
  21. package/dist/editor/editTarget.d.ts +28 -0
  22. package/dist/editor/editTarget.d.ts.map +1 -0
  23. package/dist/editor/editTarget.js +20 -0
  24. package/dist/editor/editTarget.js.map +1 -0
  25. package/dist/editor/useClientDocs.d.ts +4 -0
  26. package/dist/editor/useClientDocs.d.ts.map +1 -0
  27. package/dist/editor/useClientDocs.js +16 -0
  28. package/dist/editor/useClientDocs.js.map +1 -0
  29. package/dist/editor/useEditorSession.d.ts +13 -0
  30. package/dist/editor/useEditorSession.d.ts.map +1 -0
  31. package/dist/editor/useEditorSession.js +30 -0
  32. package/dist/editor/useEditorSession.js.map +1 -0
  33. package/dist/editor/useFlash.d.ts +4 -0
  34. package/dist/editor/useFlash.d.ts.map +1 -0
  35. package/dist/editor/useFlash.js +16 -0
  36. package/dist/editor/useFlash.js.map +1 -0
  37. package/dist/editor/useGhosts.d.ts +5 -7
  38. package/dist/editor/useGhosts.d.ts.map +1 -1
  39. package/dist/editor/useGhosts.js.map +1 -1
  40. package/dist/editor/usePageEvents.d.ts.map +1 -1
  41. package/dist/editor/usePageEvents.js +16 -16
  42. package/dist/editor/usePageEvents.js.map +1 -1
  43. package/dist/editor-kit.d.ts +10 -6
  44. package/dist/editor-kit.d.ts.map +1 -1
  45. package/dist/editor-kit.js +8 -5
  46. package/dist/editor-kit.js.map +1 -1
  47. package/dist/editors/CheckboxEditor.d.ts +6 -1
  48. package/dist/editors/CheckboxEditor.d.ts.map +1 -1
  49. package/dist/editors/CheckboxEditor.js +6 -3
  50. package/dist/editors/CheckboxEditor.js.map +1 -1
  51. package/dist/editors/ColorEditor.d.ts +3 -1
  52. package/dist/editors/ColorEditor.d.ts.map +1 -1
  53. package/dist/editors/ColorEditor.js +9 -15
  54. package/dist/editors/ColorEditor.js.map +1 -1
  55. package/dist/editors/DateEditor.d.ts +3 -1
  56. package/dist/editors/DateEditor.d.ts.map +1 -1
  57. package/dist/editors/DateEditor.js +10 -12
  58. package/dist/editors/DateEditor.js.map +1 -1
  59. package/dist/editors/InlineText.d.ts +2 -2
  60. package/dist/editors/InlineText.d.ts.map +1 -1
  61. package/dist/editors/InlineText.js +2 -3
  62. package/dist/editors/InlineText.js.map +1 -1
  63. package/dist/editors/NumberEditor.d.ts +3 -1
  64. package/dist/editors/NumberEditor.d.ts.map +1 -1
  65. package/dist/editors/NumberEditor.js +9 -17
  66. package/dist/editors/NumberEditor.js.map +1 -1
  67. package/dist/editors/RelationshipEditor.d.ts +7 -1
  68. package/dist/editors/RelationshipEditor.d.ts.map +1 -1
  69. package/dist/editors/RelationshipEditor.js +10 -18
  70. package/dist/editors/RelationshipEditor.js.map +1 -1
  71. package/dist/editors/SelectEditor.d.ts +4 -1
  72. package/dist/editors/SelectEditor.d.ts.map +1 -1
  73. package/dist/editors/SelectEditor.js +8 -13
  74. package/dist/editors/SelectEditor.js.map +1 -1
  75. package/dist/editors/UploadEditor.d.ts +7 -1
  76. package/dist/editors/UploadEditor.d.ts.map +1 -1
  77. package/dist/editors/UploadEditor.js +5 -5
  78. package/dist/editors/UploadEditor.js.map +1 -1
  79. package/dist/editors/index.d.ts +0 -1
  80. package/dist/editors/index.d.ts.map +1 -1
  81. package/dist/editors/index.js +56 -16
  82. package/dist/editors/index.js.map +1 -1
  83. package/dist/editors/kit.d.ts +39 -0
  84. package/dist/editors/kit.d.ts.map +1 -0
  85. package/dist/editors/kit.js +44 -0
  86. package/dist/editors/kit.js.map +1 -0
  87. package/dist/editors/useDebouncedSearch.d.ts +10 -6
  88. package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
  89. package/dist/editors/useDebouncedSearch.js +11 -7
  90. package/dist/editors/useDebouncedSearch.js.map +1 -1
  91. package/dist/geometry.d.ts +49 -0
  92. package/dist/geometry.d.ts.map +1 -0
  93. package/dist/geometry.js +48 -0
  94. package/dist/geometry.js.map +1 -0
  95. package/dist/hub.d.ts +4 -4
  96. package/dist/hub.d.ts.map +1 -1
  97. package/dist/hub.js.map +1 -1
  98. package/dist/index.d.ts +3 -3
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +2 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/layout.d.ts.map +1 -1
  103. package/dist/layout.js +4 -2
  104. package/dist/layout.js.map +1 -1
  105. package/dist/locators.d.ts +0 -2
  106. package/dist/locators.d.ts.map +1 -1
  107. package/dist/locators.js +10 -16
  108. package/dist/locators.js.map +1 -1
  109. package/dist/messages.d.ts +15 -2
  110. package/dist/messages.d.ts.map +1 -1
  111. package/dist/messages.js +5 -0
  112. package/dist/messages.js.map +1 -1
  113. package/dist/registry.d.ts +21 -2
  114. package/dist/registry.d.ts.map +1 -1
  115. package/dist/registry.js +31 -6
  116. package/dist/registry.js.map +1 -1
  117. package/dist/rowOps.d.ts +20 -0
  118. package/dist/rowOps.d.ts.map +1 -0
  119. package/dist/rowOps.js +48 -0
  120. package/dist/rowOps.js.map +1 -0
  121. package/dist/sanitize.d.ts +2 -0
  122. package/dist/sanitize.d.ts.map +1 -1
  123. package/dist/sanitize.js +3 -0
  124. package/dist/sanitize.js.map +1 -1
  125. package/dist/settings.d.ts +10 -0
  126. package/dist/settings.d.ts.map +1 -0
  127. package/dist/settings.js +30 -0
  128. package/dist/settings.js.map +1 -0
  129. package/dist/styles.d.ts +0 -4
  130. package/dist/styles.d.ts.map +1 -1
  131. package/dist/styles.js +2 -1
  132. package/dist/styles.js.map +1 -1
  133. package/dist/targets.d.ts +1 -1
  134. package/dist/targets.d.ts.map +1 -1
  135. package/dist/types.d.ts +71 -46
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/ui/BlockPicker.d.ts.map +1 -1
  138. package/dist/ui/BlockPicker.js +6 -5
  139. package/dist/ui/BlockPicker.js.map +1 -1
  140. package/dist/ui/Dialog.d.ts +0 -5
  141. package/dist/ui/Dialog.d.ts.map +1 -1
  142. package/dist/ui/Dialog.js +10 -6
  143. package/dist/ui/Dialog.js.map +1 -1
  144. package/dist/ui/Dock.d.ts +1 -6
  145. package/dist/ui/Dock.d.ts.map +1 -1
  146. package/dist/ui/Dock.js +46 -28
  147. package/dist/ui/Dock.js.map +1 -1
  148. package/dist/ui/Overlay.d.ts.map +1 -1
  149. package/dist/ui/Overlay.js +39 -37
  150. package/dist/ui/Overlay.js.map +1 -1
  151. package/dist/ui/PreviewToolbar.d.ts +3 -3
  152. package/dist/ui/PreviewToolbar.d.ts.map +1 -1
  153. package/dist/ui/PreviewToolbar.js +4 -6
  154. package/dist/ui/PreviewToolbar.js.map +1 -1
  155. package/dist/ui/SettingsPanel.d.ts +0 -9
  156. package/dist/ui/SettingsPanel.d.ts.map +1 -1
  157. package/dist/ui/SettingsPanel.js +36 -41
  158. package/dist/ui/SettingsPanel.js.map +1 -1
  159. package/dist/ui/ToolbarButton.d.ts +12 -0
  160. package/dist/ui/ToolbarButton.d.ts.map +1 -0
  161. package/dist/ui/ToolbarButton.js +7 -0
  162. package/dist/ui/ToolbarButton.js.map +1 -0
  163. package/dist/useEvent.d.ts +1 -1
  164. package/dist/useEvent.d.ts.map +1 -1
  165. package/dist/useEvent.js +3 -1
  166. package/dist/useEvent.js.map +1 -1
  167. package/dist/useIsClient.d.ts +5 -0
  168. package/dist/useIsClient.d.ts.map +1 -0
  169. package/dist/useIsClient.js +8 -0
  170. package/dist/useIsClient.js.map +1 -0
  171. package/package.json +2 -2
  172. package/src/OnPageClient.tsx +9 -5
  173. package/src/annotations.ts +1 -1
  174. package/src/context.tsx +53 -10
  175. package/src/dom.ts +17 -18
  176. package/src/editor/FieldHost.tsx +26 -11
  177. package/src/editor/OnPageEditor.tsx +103 -160
  178. package/src/editor/editTarget.ts +41 -0
  179. package/src/editor/useClientDocs.ts +19 -0
  180. package/src/editor/useEditorSession.ts +36 -0
  181. package/src/editor/useFlash.ts +18 -0
  182. package/src/editor/useGhosts.ts +5 -7
  183. package/src/editor/usePageEvents.ts +13 -14
  184. package/src/editor-kit.ts +10 -6
  185. package/src/editors/CheckboxEditor.tsx +11 -12
  186. package/src/editors/ColorEditor.tsx +15 -35
  187. package/src/editors/DateEditor.tsx +18 -37
  188. package/src/editors/InlineText.tsx +4 -5
  189. package/src/editors/NumberEditor.tsx +15 -36
  190. package/src/editors/RelationshipEditor.tsx +18 -36
  191. package/src/editors/SelectEditor.tsx +14 -22
  192. package/src/editors/UploadEditor.tsx +6 -6
  193. package/src/editors/index.ts +56 -16
  194. package/src/editors/kit.tsx +97 -0
  195. package/src/editors/useDebouncedSearch.ts +18 -10
  196. package/src/geometry.ts +62 -0
  197. package/src/hub.ts +5 -5
  198. package/src/index.ts +5 -2
  199. package/src/layout.ts +5 -2
  200. package/src/locators.ts +11 -14
  201. package/src/messages.ts +26 -5
  202. package/src/registry.ts +42 -8
  203. package/src/rowOps.ts +64 -0
  204. package/src/sanitize.ts +6 -0
  205. package/src/settings.ts +49 -0
  206. package/src/styles.ts +2 -1
  207. package/src/targets.ts +1 -1
  208. package/src/types.ts +78 -45
  209. package/src/ui/BlockPicker.tsx +6 -7
  210. package/src/ui/Dialog.tsx +9 -5
  211. package/src/ui/Dock.tsx +67 -50
  212. package/src/ui/Overlay.tsx +75 -102
  213. package/src/ui/PreviewToolbar.tsx +7 -15
  214. package/src/ui/SettingsPanel.tsx +84 -81
  215. package/src/ui/ToolbarButton.tsx +36 -0
  216. package/src/useEvent.ts +4 -2
  217. package/src/useIsClient.ts +15 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What clicking or pressing Enter on a field opens: a pure function of the
3
+ * document's state, the editor registry and what was clicked.
4
+ */
5
+ import {
6
+ fieldAtPath,
7
+ isEditablePath,
8
+ isReady,
9
+ type DocumentState,
10
+ type RefKey,
11
+ type TargetKind,
12
+ } from '@payload-onpage/core'
13
+ import { resolveEditor } from '../registry.ts'
14
+ import type { EditingState, EditorRegistry } from '../types.ts'
15
+
16
+ export type EditTarget =
17
+ /** The document isn't loaded (yet, or it failed). */
18
+ | { kind: 'not-ready'; state: DocumentState | undefined }
19
+ /** Nothing here is edited in place. */
20
+ | { kind: 'none' }
21
+ | { kind: 'edit'; editing: EditingState }
22
+
23
+ export function resolveEditTarget(
24
+ state: DocumentState | undefined,
25
+ editors: EditorRegistry,
26
+ key: RefKey,
27
+ path: string,
28
+ opts: {
29
+ point?: { x: number; y: number } | undefined
30
+ kind?: TargetKind | undefined
31
+ el?: Element | undefined
32
+ },
33
+ ): EditTarget {
34
+ if (!isReady(state)) return { kind: 'not-ready', state }
35
+ if (!isEditablePath(state.schema, path)) return { kind: 'none' }
36
+ const hit = fieldAtPath(state.schema.fields, state.doc, path)
37
+ if (!hit) return { kind: 'none' }
38
+ const editor = resolveEditor(editors, hit.field, opts.kind === 'field' ? undefined : opts.kind)
39
+ if (!editor) return { kind: 'none' }
40
+ return { kind: 'edit', editing: { key, path, editor, point: opts.point, el: opts.el } }
41
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import type { RefKey } from '@payload-onpage/core'
4
+ import { useCallback, useState } from 'react'
5
+
6
+ /** Documents rendered from editor state on this page (client mode), and how they register. */
7
+ export function useClientDocs(): [ReadonlySet<RefKey>, (key: RefKey) => () => void] {
8
+ const [docs, setDocs] = useState<ReadonlySet<RefKey>>(() => new Set())
9
+ const register = useCallback((key: RefKey) => {
10
+ setDocs((s) => new Set(s).add(key))
11
+ return () =>
12
+ setDocs((s) => {
13
+ const next = new Set(s)
14
+ next.delete(key)
15
+ return next
16
+ })
17
+ }, [])
18
+ return [docs, register]
19
+ }
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { EditorSession, type DataSource, type DocRef } from '@payload-onpage/core'
4
+ import { useEffect, useState } from 'react'
5
+ import { useEvent } from '../useEvent.ts'
6
+
7
+ /**
8
+ * One editor session per data source and locale, disposed (after flushing its
9
+ * saves) when they change or on unmount. It lives in state, so StrictMode's
10
+ * mount/unmount/mount gets a fresh one each time.
11
+ */
12
+ export function useEditorSession(opts: {
13
+ dataSource: DataSource
14
+ locale: string | undefined
15
+ autosaveDelay: number | undefined
16
+ onSaved: (ref: DocRef, seq: number) => void
17
+ }): EditorSession | null {
18
+ const { dataSource, locale, autosaveDelay } = opts
19
+ const onSaved = useEvent(opts.onSaved)
20
+ const [session, setSession] = useState<EditorSession | null>(null)
21
+ useEffect(() => {
22
+ const s = new EditorSession({
23
+ dataSource,
24
+ locale,
25
+ autosaveDelay,
26
+ onSaved: (ref, seq) => onSaved(ref, seq),
27
+ })
28
+ // The session is an external resource with a lifecycle (see above).
29
+ // eslint-disable-next-line react-hooks/set-state-in-effect
30
+ setSession(s)
31
+ return () => {
32
+ void s.flush().finally(() => s.dispose())
33
+ }
34
+ }, [dataSource, locale, autosaveDelay, onSaved])
35
+ return session
36
+ }
@@ -0,0 +1,18 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import type { Flash } from '../types.ts'
5
+
6
+ const SHOWN_FOR = 4000
7
+
8
+ /** The dock's short message, cleared after a few seconds. */
9
+ export function useFlash(): [Flash | null, (text: string, bad?: boolean) => void] {
10
+ const [flash, setFlash] = useState<Flash | null>(null)
11
+ useEffect(() => {
12
+ if (!flash) return
13
+ const id = setTimeout(() => setFlash(null), SHOWN_FOR)
14
+ return () => clearTimeout(id)
15
+ }, [flash])
16
+ const notify = useCallback((text: string, bad?: boolean) => setFlash({ text, bad }), [])
17
+ return [flash, notify]
18
+ }
@@ -6,16 +6,14 @@
6
6
  * records the document's save sequence when it was made; the render after a
7
7
  * later save retires it. Failed saves and a TTL retire ghosts too.
8
8
  */
9
- import type { SessionSnapshot } from '@payload-onpage/core'
9
+ import type { SessionSnapshot, RefKey } from '@payload-onpage/core'
10
+ import type { GhostInput } from '../types.ts'
10
11
  import { useCallback, useEffect, useState } from 'react'
11
12
 
12
- export interface Ghost {
13
+ export interface Ghost extends GhostInput {
13
14
  id: string
14
- key: string
15
+ key: RefKey
15
16
  path: string
16
- html: string
17
- className?: string
18
- style?: Partial<CSSStyleDeclaration>
19
17
  /** Save sequence of the document when the ghost was made. */
20
18
  afterSeq: number
21
19
  until: number
@@ -35,7 +33,7 @@ export function useGhosts(snap: SessionSnapshot | null) {
35
33
  }, [])
36
34
 
37
35
  /** The page re-rendered after save `seq` of document `key`. */
38
- const rendered = useCallback((key: string, seq: number) => {
36
+ const rendered = useCallback((key: RefKey, seq: number) => {
39
37
  setGhosts((gs) =>
40
38
  gs.some((g) => g.key === key && g.afterSeq < seq)
41
39
  ? gs.filter((g) => !(g.key === key && g.afterSeq < seq))
@@ -8,13 +8,14 @@
8
8
  * - a warning before leaving with unsaved changes
9
9
  */
10
10
  import { useEvent } from '../useEvent.ts'
11
- import { moveItem, refKey, removeItem, resolveRow, updateArray } from '@payload-onpage/core'
11
+ import { refKey, resolveRow, isReady } from '@payload-onpage/core'
12
+ import { rowOps } from '../rowOps.ts'
12
13
  import { useEffect } from 'react'
13
- import { isOurs, isTyping } from '../dom.ts'
14
+ import { DOM_ATTR, isOurs, isTyping } from '../dom.ts'
14
15
  import { isFieldTarget, isRowTarget, targetsAt } from '../targets.ts'
15
16
  import type { OnPageContextValue } from '../types.ts'
16
17
 
17
- const FOCUSABLE_FLAG = 'data-onpage-focusable'
18
+ const FOCUSABLE_FLAG = DOM_ATTR.focusable
18
19
 
19
20
  export function usePageEvents(ctx: OnPageContextValue | null, root: () => ParentNode | null): void {
20
21
  const onClick = useEvent((e: MouseEvent) => {
@@ -43,22 +44,20 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
43
44
  }
44
45
  })
45
46
 
47
+ /** ⌥↑/⌥↓ move the selected row, ⌥⌫ deletes it. True when the key was ours (even if the row can't move). */
46
48
  const moveSelected = (dir: -1 | 1 | 'delete') => {
47
49
  if (!ctx?.selected) return false
48
50
  const { key, path } = ctx.selected
49
51
  const state = ctx.snap.docs[key]
50
- const info = state?.schema && state.doc ? resolveRow(state.schema, state.doc, path) : null
52
+ const info = isReady(state) ? resolveRow(state.schema, state.doc, path) : null
51
53
  if (!info) return false
54
+ const ops = rowOps(ctx.session, key, info)
52
55
  if (dir === 'delete') {
53
- if (info.length <= (info.listField.minRows ?? 0)) return true
54
- ctx.session.apply(key, (d) => updateArray(d, info.listPath, (a) => removeItem(a, info.index)))
55
- ctx.setSelected(null)
56
- return true
56
+ if (ops.remove()) ctx.setSelected(null)
57
+ } else {
58
+ const next = ops.move(dir)
59
+ if (next) ctx.setSelected(next)
57
60
  }
58
- const to = info.index + dir
59
- if (to < 0 || to >= info.length) return true
60
- ctx.session.apply(key, (d) => updateArray(d, info.listPath, (a) => moveItem(a, info.index, to)))
61
- ctx.setSelected({ key, path: `${info.listPath}.${to}` })
62
61
  return true
63
62
  }
64
63
 
@@ -120,7 +119,7 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
120
119
  })
121
120
 
122
121
  useEffect(() => {
123
- document.documentElement.setAttribute('data-onpage-editing', '')
122
+ document.documentElement.setAttribute(DOM_ATTR.editing, '')
124
123
  const click = (e: MouseEvent) => onClick(e)
125
124
  const key = (e: KeyboardEvent) => onKey(e)
126
125
  const focus = (e: FocusEvent) => onFocus(e)
@@ -130,7 +129,7 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
130
129
  document.addEventListener('focusin', focus)
131
130
  window.addEventListener('beforeunload', unload)
132
131
  return () => {
133
- document.documentElement.removeAttribute('data-onpage-editing')
132
+ document.documentElement.removeAttribute(DOM_ATTR.editing)
134
133
  document.removeEventListener('click', click, true)
135
134
  document.removeEventListener('keydown', key)
136
135
  document.removeEventListener('focusin', focus)
package/src/editor-kit.ts CHANGED
@@ -2,14 +2,17 @@
2
2
  * @payload-onpage/react/editor-kit — building blocks for custom field editors
3
3
  * (the default editors and @payload-onpage/lexical use exactly these).
4
4
  *
5
- * function JsonEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
6
- * return <Dialog variant="popover" anchor={element} title={field.label} onClose={close} t={t}>…</Dialog>
7
- * }
5
+ * const json = defineEditor({
6
+ * mode: 'popover',
7
+ * accepts: ['json'],
8
+ * component: (props) => <EditorPopover props={props} testId="my-json">…</EditorPopover>,
9
+ * })
8
10
  */
9
11
  export { Dialog, type DialogProps } from './ui/Dialog.tsx'
10
12
  export { useOnPage } from './context.tsx'
11
13
  export { useDebouncedSearch } from './editors/useDebouncedSearch.ts'
12
- export { escapeHtml } from './editors/InlineText.tsx'
14
+ export { ApplyBar, EditorPopover, useDraft } from './editors/kit.tsx'
15
+ export { canEdit, defineEditor } from './registry.ts'
13
16
  export {
14
17
  HIDDEN,
15
18
  UI,
@@ -22,6 +25,7 @@ export {
22
25
  } from './dom.ts'
23
26
  export { useTrackElement, useLayoutTick, notifyLayout } from './layout.ts'
24
27
  export { useEvent } from './useEvent.ts'
25
- export { sanitizeHtml } from './sanitize.ts'
26
- export type { EditorRegistry, FieldEditorDef, FieldEditorProps } from './types.ts'
28
+ export { escapeHtml, sanitizeHtml } from './sanitize.ts'
29
+ export type { EditorRegistry, FieldEditorDef, FieldEditorProps, GhostInput } from './types.ts'
30
+ export type { FieldOf } from '@payload-onpage/core'
27
31
  export type { Translate } from './messages.ts'
@@ -1,23 +1,22 @@
1
1
  'use client'
2
2
 
3
+ import type { ScalarField } from '@payload-onpage/core'
3
4
  import type { FieldEditorProps } from '../types.ts'
4
- import { Dialog } from '../ui/Dialog.tsx'
5
+ import { EditorPopover } from './kit.tsx'
5
6
 
6
- export function CheckboxEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
7
+ /** A checkbox value (unset falls back to the field's default). */
8
+ export const checkedOf = (raw: unknown, field: { defaultValue?: unknown }): boolean =>
9
+ Boolean(raw ?? field.defaultValue)
10
+
11
+ export function CheckboxEditor(props: FieldEditorProps<ScalarField, boolean>) {
12
+ const { field, value, commit, close } = props
7
13
  return (
8
- <Dialog
9
- variant="popover"
10
- anchor={element}
11
- title={field.label}
12
- onClose={close}
13
- t={t}
14
- testId="onpage-checkbox"
15
- >
14
+ <EditorPopover props={props} testId="onpage-checkbox">
16
15
  <label className="po-check">
17
16
  <input
18
17
  type="checkbox"
19
18
  data-autofocus
20
- checked={Boolean(value ?? field.defaultValue)}
19
+ checked={value}
21
20
  onChange={(e) => {
22
21
  commit(e.target.checked)
23
22
  close()
@@ -26,6 +25,6 @@ export function CheckboxEditor({ field, value, element, commit, close, t }: Fiel
26
25
  />
27
26
  {field.label}
28
27
  </label>
29
- </Dialog>
28
+ </EditorPopover>
30
29
  )
31
30
  }
@@ -1,41 +1,23 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import type { ScalarField } from '@payload-onpage/core'
4
4
  import type { FieldEditorProps } from '../types.ts'
5
- import { Dialog } from '../ui/Dialog.tsx'
5
+ import { ApplyBar, EditorPopover, useDraft } from './kit.tsx'
6
6
 
7
7
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
8
8
 
9
+ export const toColor = (raw: unknown): string | null =>
10
+ typeof raw === 'string' && raw ? raw : null
11
+
9
12
  /** 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
- }
13
+ export function ColorEditor(props: FieldEditorProps<ScalarField, string | null>) {
14
+ const { field, t } = props
15
+ const { draft, change, save, onKeyDown } = useDraft(props, {
16
+ format: (v) => v ?? '',
17
+ parse: (d) => (d === '' ? null : HEX.test(d) ? d : undefined),
18
+ })
29
19
  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
- >
20
+ <EditorPopover props={props} testId="onpage-color" width={260}>
39
21
  <div className="po-row po-inline-row">
40
22
  <input
41
23
  type="color"
@@ -52,13 +34,11 @@ export function ColorEditor({
52
34
  placeholder="#1e40af"
53
35
  aria-invalid={draft !== '' && !HEX.test(draft)}
54
36
  onChange={(e) => change(e.target.value)}
55
- onKeyDown={(e) => e.key === 'Enter' && save()}
37
+ onKeyDown={onKeyDown}
56
38
  data-testid="onpage-color-input"
57
39
  />
58
40
  </div>
59
- <button type="button" className="po-btn po-primary" onClick={save} data-testid="onpage-apply">
60
- {t('common.apply')}
61
- </button>
62
- </Dialog>
41
+ <ApplyBar onApply={save} t={t} />
42
+ </EditorPopover>
63
43
  )
64
44
  }
@@ -1,12 +1,12 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import type { DateField } from '@payload-onpage/core'
4
4
  import type { FieldEditorProps } from '../types.ts'
5
- import { Dialog } from '../ui/Dialog.tsx'
5
+ import { ApplyBar, EditorPopover, useDraft } from './kit.tsx'
6
6
 
7
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 ''
8
+ const toLocalInput = (iso: string | null, withTime: boolean): string => {
9
+ if (!iso) return ''
10
10
  const d = new Date(iso)
11
11
  if (Number.isNaN(d.getTime())) return ''
12
12
  const pad = (n: number) => String(n).padStart(2, '0')
@@ -14,24 +14,19 @@ const toLocalInput = (iso: unknown, withTime: boolean): string => {
14
14
  return withTime ? `${date}T${pad(d.getHours())}:${pad(d.getMinutes())}` : date
15
15
  }
16
16
 
17
- export function DateEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
17
+ export const toIsoDate = (raw: unknown): string | null =>
18
+ typeof raw === 'string' && raw ? raw : null
19
+
20
+ export function DateEditor(props: FieldEditorProps<DateField, string | null>) {
21
+ const { field, t } = props
18
22
  const withTime = field.pickerAppearance === 'dayAndTime' || field.pickerAppearance === 'timeOnly'
19
- const [draft, setDraft] = useState(toLocalInput(value, withTime))
20
- const save = () => {
23
+ const { draft, change, save, onKeyDown, clear } = useDraft(props, {
24
+ format: (v) => toLocalInput(v, withTime),
21
25
  // 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
+ parse: (d) => (d ? new Date(withTime ? d : `${d}T12:00:00`).toISOString() : null),
27
+ })
26
28
  return (
27
- <Dialog
28
- variant="popover"
29
- anchor={element}
30
- title={field.label}
31
- onClose={close}
32
- t={t}
33
- testId="onpage-date"
34
- >
29
+ <EditorPopover props={props} testId="onpage-date">
35
30
  <div className="po-row">
36
31
  <input
37
32
  className="po-input"
@@ -39,26 +34,12 @@ export function DateEditor({ field, value, element, commit, close, t }: FieldEdi
39
34
  aria-label={field.label}
40
35
  data-autofocus
41
36
  value={draft}
42
- onChange={(e) => setDraft(e.target.value)}
43
- onKeyDown={(e) => e.key === 'Enter' && save()}
37
+ onChange={(e) => change(e.target.value)}
38
+ onKeyDown={onKeyDown}
44
39
  data-testid="onpage-date-input"
45
40
  />
46
41
  </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>
42
+ <ApplyBar onApply={save} onClear={field.required ? undefined : clear} t={t} />
43
+ </EditorPopover>
63
44
  )
64
45
  }
@@ -11,13 +11,12 @@
11
11
  * cancels.
12
12
  */
13
13
  import { useLayoutEffect, useRef } from 'react'
14
- import { toText } from '@payload-onpage/core'
14
+ import type { ScalarField } from '@payload-onpage/core'
15
15
  import { copyTypography, HIDDEN, placeCaret, placeOver, typographyOf } from '../dom.ts'
16
16
  import { useTrackElement } from '../layout.ts'
17
17
  import type { FieldEditorProps } from '../types.ts'
18
18
 
19
- export const escapeHtml = (s: string): string =>
20
- s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
19
+ import { escapeHtml } from '../sanitize.ts'
21
20
 
22
21
  export function InlineText({
23
22
  field,
@@ -30,10 +29,10 @@ export function InlineText({
30
29
  live,
31
30
  point,
32
31
  t,
33
- }: FieldEditorProps) {
32
+ }: FieldEditorProps<ScalarField, string>) {
34
33
  const multiline = field.type === 'textarea'
35
34
  const ref = useRef<HTMLDivElement>(null)
36
- const initial = useRef(toText(value))
35
+ const initial = useRef(value)
37
36
  const done = useRef(false)
38
37
 
39
38
  useLayoutEffect(() => {
@@ -1,41 +1,22 @@
1
1
  'use client'
2
2
 
3
- import { toText } from '@payload-onpage/core'
4
- import { useState } from 'react'
3
+ import type { NumberField } from '@payload-onpage/core'
5
4
  import type { FieldEditorProps } from '../types.ts'
6
- import { Dialog } from '../ui/Dialog.tsx'
5
+ import { ApplyBar, EditorPopover, useDraft } from './kit.tsx'
6
+
7
+ export const toNumber = (raw: unknown): number | null =>
8
+ typeof raw === 'number' && Number.isFinite(raw) ? raw : null
7
9
 
8
10
  /** 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))
11
+ export function NumberEditor(props: FieldEditorProps<NumberField, number | null>) {
12
+ const { field, t } = props
13
+ const { draft, change, save, onKeyDown } = useDraft(props, {
14
+ format: (v) => (v === null ? '' : String(v)),
15
+ parse: (d) => (d === '' ? null : Number.isNaN(Number(d)) ? undefined : Number(d)),
16
+ })
20
17
  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
18
  return (
31
- <Dialog
32
- variant="popover"
33
- anchor={element}
34
- title={field.label}
35
- onClose={close}
36
- t={t}
37
- testId="onpage-number"
38
- >
19
+ <EditorPopover props={props} testId="onpage-number">
39
20
  <div className="po-row">
40
21
  {slider && (
41
22
  <input
@@ -57,13 +38,11 @@ export function NumberEditor({
57
38
  max={field.max}
58
39
  value={draft}
59
40
  onChange={(e) => change(e.target.value)}
60
- onKeyDown={(e) => e.key === 'Enter' && save()}
41
+ onKeyDown={onKeyDown}
61
42
  data-testid="onpage-number-input"
62
43
  />
63
44
  </div>
64
- <button type="button" className="po-btn po-primary" onClick={save} data-testid="onpage-apply">
65
- {t('common.apply')}
66
- </button>
67
- </Dialog>
45
+ <ApplyBar onApply={save} t={t} />
46
+ </EditorPopover>
68
47
  )
69
48
  }
@@ -5,43 +5,32 @@
5
5
  * (Payload REST, so access control applies) and pick one, or several for
6
6
  * hasMany. Polymorphic fields get a collection switch and store `{ relationTo, value }`.
7
7
  */
8
- import { relationId, relationTitle, type SearchHit } from '@payload-onpage/core'
8
+ import {
9
+ asList,
10
+ relationId,
11
+ relationTitle,
12
+ type RelationField,
13
+ type SearchHit,
14
+ } from '@payload-onpage/core'
9
15
  import { useCallback, useState } from 'react'
10
16
  import { useOnPage } from '../context.tsx'
11
17
  import type { FieldEditorProps } from '../types.ts'
12
- import { Dialog } from '../ui/Dialog.tsx'
18
+ import { EditorPopover } from './kit.tsx'
13
19
  import { useDebouncedSearch } from './useDebouncedSearch.ts'
14
20
 
15
- export function RelationshipEditor({
16
- docKey,
17
- field,
18
- value,
19
- element,
20
- commit,
21
- close,
22
- t,
23
- }: FieldEditorProps) {
21
+ export function RelationshipEditor(props: FieldEditorProps<RelationField>) {
22
+ const { docKey, field, value, commit, close, t } = props
24
23
  const { session, snap } = useOnPage()
25
- const targets = Array.isArray(field.relationTo)
26
- ? field.relationTo
27
- : field.relationTo
28
- ? [field.relationTo]
29
- : []
24
+ const targets = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]
30
25
  const poly = Array.isArray(field.relationTo)
31
26
  const [collection, setCollection] = useState(targets[0] ?? '')
32
27
  const relations = snap.docs[docKey]?.schema?.relations ?? {}
33
28
  const titleField = relations[collection]?.titleField
34
29
  const many = !!field.hasMany
35
- const current: unknown[] = many
36
- ? Array.isArray(value)
37
- ? value
38
- : []
39
- : value == null
40
- ? []
41
- : [value]
30
+ const current = asList(value, many)
42
31
  const ds = session.dataSource
43
32
  const search = useCallback(
44
- async (query: string) =>
33
+ async (query: string, _page: number, signal: AbortSignal) =>
45
34
  ds.search
46
35
  ? {
47
36
  items: await ds.search(collection, {
@@ -49,13 +38,14 @@ export function RelationshipEditor({
49
38
  titleField,
50
39
  locale: session.locale,
51
40
  limit: 12,
41
+ signal,
52
42
  }),
53
43
  hasMore: false,
54
44
  }
55
45
  : { items: [] as SearchHit[], hasMore: false },
56
46
  [ds, collection, titleField, session.locale],
57
47
  )
58
- const results = useDebouncedSearch<SearchHit>(ds.search ? search : null, [])
48
+ const results = useDebouncedSearch<SearchHit>(ds.search ? search : null)
59
49
 
60
50
  const wrap = (hit: SearchHit): unknown =>
61
51
  poly ? { relationTo: collection, value: hit.doc } : hit.doc
@@ -75,15 +65,7 @@ export function RelationshipEditor({
75
65
  const remove = (i: number) => commit(many ? current.filter((_, j) => j !== i) : null)
76
66
 
77
67
  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
- >
68
+ <EditorPopover props={props} testId="onpage-relationship" width={320}>
87
69
  {current.map((v, i) => (
88
70
  <div className="po-rel-item" key={`${String(relationId(v))}-${i}`}>
89
71
  {relationTitle(v, titleField)}
@@ -91,7 +73,7 @@ export function RelationshipEditor({
91
73
  <button
92
74
  type="button"
93
75
  onClick={() => remove(i)}
94
- aria-label={`${t('common.remove')} ${relationTitle(v, titleField)}`}
76
+ aria-label={t('common.removeItem', { item: relationTitle(v, titleField) })}
95
77
  >
96
78
  ✕
97
79
  </button>
@@ -148,6 +130,6 @@ export function RelationshipEditor({
148
130
  </div>
149
131
  </>
150
132
  )}
151
- </Dialog>
133
+ </EditorPopover>
152
134
  )
153
135
  }