@payload-onpage/react 0.0.0-stage → 0.2.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,123 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Preview mode: the draft without editing tools. Device widths open the same
5
+ * page in a framed viewport. Signed-in editors can switch back to editing;
6
+ * people on a shared link can leave to the published page.
7
+ */
8
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import { UI } from '../dom.ts'
11
+ import { createTranslate, type Messages } from '../messages.ts'
12
+ import { injectStyles } from '../styles.ts'
13
+ import { PostButton } from './PostButton.tsx'
14
+
15
+ export interface PreviewToolbarProps {
16
+ /** Endpoint that leaves preview (POST), back to the published page. */
17
+ exitHref: string
18
+ /** Endpoint that switches to editing (POST); omit for people who can't edit (shared links). */
19
+ editHref?: string
20
+ /** Opened from a shared preview link. */
21
+ shared?: boolean
22
+ messages?: Messages
23
+ }
24
+
25
+ const DEVICES = [
26
+ { id: 'phone', width: 390 },
27
+ { id: 'tablet', width: 820 },
28
+ ] as const
29
+
30
+ export const FRAME_PARAM = 'onpage-frame'
31
+
32
+ /** True inside the device-preview frame (the toolbar and editor stay out of it). */
33
+ export const inPreviewFrame = (): boolean =>
34
+ typeof window !== 'undefined' &&
35
+ (window.self !== window.top || new URLSearchParams(window.location.search).has(FRAME_PARAM))
36
+
37
+ const noop = () => () => {}
38
+ const useMounted = () =>
39
+ useSyncExternalStore(
40
+ noop,
41
+ () => true,
42
+ () => false,
43
+ )
44
+
45
+ export function PreviewToolbar({ exitHref, editHref, shared, messages }: PreviewToolbarProps) {
46
+ const [device, setDevice] = useState<(typeof DEVICES)[number] | null>(null)
47
+ const mounted = useMounted()
48
+ const t = useMemo(() => createTranslate(messages), [messages])
49
+ useEffect(() => injectStyles(), [])
50
+ useEffect(() => {
51
+ if (!device) return
52
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setDevice(null)
53
+ document.addEventListener('keydown', onKey)
54
+ return () => document.removeEventListener('keydown', onKey)
55
+ }, [device])
56
+ if (!mounted || inPreviewFrame()) return null
57
+
58
+ const frameSrc = () => {
59
+ const u = new URL(window.location.href)
60
+ u.searchParams.set(FRAME_PARAM, '1')
61
+ return u.toString()
62
+ }
63
+
64
+ return createPortal(
65
+ <div className="po-layer" {...{ [UI]: '' }}>
66
+ {device && (
67
+ <div className="po-frame-bg" data-testid="onpage-device-frame">
68
+ <button
69
+ type="button"
70
+ className="po-frame-close"
71
+ onClick={() => setDevice(null)}
72
+ aria-label={t('common.close')}
73
+ />
74
+ <iframe
75
+ src={frameSrc()}
76
+ title={t(`preview.${device.id}`)}
77
+ style={{ width: device.width }}
78
+ />
79
+ </div>
80
+ )}
81
+ <div
82
+ className="po-preview"
83
+ role="toolbar"
84
+ aria-label={t('preview.label')}
85
+ data-testid="onpage-preview-toolbar"
86
+ >
87
+ <span className="po-badge">{t('preview.badge')}</span>
88
+ <span className="po-status">{t(shared ? 'preview.shared' : 'preview.own')}</span>
89
+ {DEVICES.map((d) => (
90
+ <button
91
+ key={d.id}
92
+ type="button"
93
+ className="po-btn"
94
+ aria-pressed={device?.id === d.id}
95
+ onClick={() => setDevice(device?.id === d.id ? null : d)}
96
+ data-testid={`onpage-device-${d.id}`}
97
+ >
98
+ {t(`preview.${d.id}`)}
99
+ </button>
100
+ ))}
101
+ {device && (
102
+ <button
103
+ type="button"
104
+ className="po-btn"
105
+ onClick={() => setDevice(null)}
106
+ data-testid="onpage-device-desktop"
107
+ >
108
+ {t('preview.desktop')}
109
+ </button>
110
+ )}
111
+ {editHref && (
112
+ <PostButton action={editHref} className="po-btn po-primary" testId="onpage-edit">
113
+ {t('preview.edit')}
114
+ </PostButton>
115
+ )}
116
+ <PostButton action={exitHref} testId="onpage-exit-preview">
117
+ {t(shared ? 'preview.viewPublished' : 'preview.exit')}
118
+ </PostButton>
119
+ </div>
120
+ </div>,
121
+ document.body,
122
+ )
123
+ }
@@ -0,0 +1,279 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Settings for one section or item, generated from the schema: every value
5
+ * that isn't edited directly on the page — selects (layout, background, width,
6
+ * spacing…), checkboxes, numbers, dates, relationships, URLs and other plain
7
+ * text, images that aren't on the page. Changes apply immediately.
8
+ */
9
+ import { fieldAtPath, getAt, relationTitle, toText, type SchemaField } from '@payload-onpage/core'
10
+ import { useId, useRef, useState, type KeyboardEvent, type RefObject } from 'react'
11
+ import { useOnPage } from '../context.tsx'
12
+ import type { Translate } from '../messages.ts'
13
+ import type { TargetIndex } from '../types.ts'
14
+ import { Dialog } from './Dialog.tsx'
15
+
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
+ const display = (v: unknown, t: Translate): string => {
44
+ if (v == null || v === '') return t('common.none')
45
+ if (Array.isArray(v))
46
+ return v.length ? t('common.selected', { count: v.length }) : t('common.none')
47
+ return typeof v === 'object' ? relationTitle(v) : toText(v)
48
+ }
49
+
50
+ function TextInput({
51
+ docKey,
52
+ path,
53
+ field,
54
+ id,
55
+ }: {
56
+ docKey: string
57
+ path: string
58
+ field: SchemaField
59
+ id: string
60
+ }) {
61
+ const { session, snap } = useOnPage()
62
+ const value = getAt(snap.docs[docKey]?.doc, path)
63
+ const [draft, setDraft] = useState<string | null>(null)
64
+ const shown = draft ?? toText(value)
65
+ const parse = (v: string) => (field.type === 'number' ? (v === '' ? null : Number(v)) : v)
66
+ const commit = () => {
67
+ if (draft === null) return
68
+ session.setValue(docKey, path, parse(draft))
69
+ setDraft(null)
70
+ }
71
+ return (
72
+ <input
73
+ id={id}
74
+ className="po-input"
75
+ type={field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text'}
76
+ value={shown}
77
+ onChange={(e) => {
78
+ setDraft(e.target.value)
79
+ session.setValue(docKey, path, parse(e.target.value), { transient: true })
80
+ }}
81
+ onBlur={commit}
82
+ onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
83
+ data-testid={`onpage-setting-${path}`}
84
+ />
85
+ )
86
+ }
87
+
88
+ /** A radio group with arrow-key navigation (WAI-ARIA radio pattern). */
89
+ function Segmented({
90
+ docKey,
91
+ path,
92
+ field,
93
+ labelId,
94
+ }: {
95
+ docKey: string
96
+ path: string
97
+ field: SchemaField
98
+ labelId: string
99
+ }) {
100
+ const { session, snap } = useOnPage()
101
+ const options = field.options ?? []
102
+ const current = getAt(snap.docs[docKey]?.doc, path) ?? field.defaultValue ?? ''
103
+ const index = Math.max(
104
+ 0,
105
+ options.findIndex((o) => o.value === current),
106
+ )
107
+ const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
108
+ const step =
109
+ e.key === 'ArrowRight' || e.key === 'ArrowDown'
110
+ ? 1
111
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp'
112
+ ? -1
113
+ : 0
114
+ if (!step) return
115
+ e.preventDefault()
116
+ const next = options[(index + step + options.length) % options.length]
117
+ if (next) session.setValue(docKey, path, next.value)
118
+ const group = e.currentTarget.parentElement
119
+ requestAnimationFrame(() =>
120
+ group?.querySelector<HTMLButtonElement>('[aria-checked="true"]')?.focus(),
121
+ )
122
+ }
123
+ return (
124
+ <div className="po-seg" role="radiogroup" aria-labelledby={labelId}>
125
+ {options.map((o, i) => (
126
+ <button
127
+ key={o.value}
128
+ type="button"
129
+ role="radio"
130
+ aria-checked={current === o.value}
131
+ tabIndex={i === index ? 0 : -1}
132
+ onClick={() => session.setValue(docKey, path, o.value)}
133
+ onKeyDown={onKey}
134
+ data-testid={`onpage-setting-${path}-${o.value}`}
135
+ >
136
+ {o.label}
137
+ </button>
138
+ ))}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ function Control({
144
+ docKey,
145
+ path,
146
+ field,
147
+ anchor,
148
+ id,
149
+ labelId,
150
+ }: {
151
+ docKey: string
152
+ path: string
153
+ field: SchemaField
154
+ anchor: RefObject<HTMLDivElement | null>
155
+ id: string
156
+ labelId: string
157
+ }) {
158
+ const { session, snap, edit, t } = useOnPage()
159
+ const value = getAt(snap.docs[docKey]?.doc, path)
160
+ 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 (
199
+ <button
200
+ id={id}
201
+ type="button"
202
+ className="po-input po-picker"
203
+ onClick={() => edit(docKey, path, { anchor: anchor.current ?? undefined })}
204
+ data-testid={`onpage-setting-${path}`}
205
+ >
206
+ {field.type === 'upload'
207
+ ? t(value ? 'settings.changeImage' : 'settings.chooseImage')
208
+ : display(value, t)}
209
+ </button>
210
+ )
211
+ }
212
+
213
+ function Row({ docKey, path, field }: { docKey: string; path: string; field: SchemaField }) {
214
+ const anchor = useRef<HTMLDivElement>(null)
215
+ const id = useId()
216
+ const labelId = `${id}-label`
217
+ return (
218
+ <div className="po-row" ref={anchor}>
219
+ {field.type !== 'checkbox' && (
220
+ <label id={labelId} htmlFor={id}>
221
+ {field.label}
222
+ </label>
223
+ )}
224
+ <Control
225
+ docKey={docKey}
226
+ path={path}
227
+ field={field}
228
+ anchor={anchor}
229
+ id={id}
230
+ labelId={labelId}
231
+ />
232
+ {field.description && <small>{field.description}</small>}
233
+ </div>
234
+ )
235
+ }
236
+
237
+ function Fields({ docKey, fields, path }: { docKey: string; fields: SchemaField[]; path: string }) {
238
+ const { targets } = useOnPage()
239
+ return (
240
+ <>
241
+ {fields
242
+ .filter((f) => settable(f, path, docKey, targets))
243
+ .map((f) => {
244
+ const full = `${path}.${f.name}`
245
+ if (f.type === 'group') {
246
+ return (
247
+ <fieldset key={f.name} className="po-group">
248
+ <legend className="po-h4">{f.label}</legend>
249
+ <Fields docKey={docKey} fields={f.fields ?? []} path={full} />
250
+ </fieldset>
251
+ )
252
+ }
253
+ return <Row key={f.name} docKey={docKey} path={full} field={f} />
254
+ })}
255
+ </>
256
+ )
257
+ }
258
+
259
+ export function SettingsPanel() {
260
+ const { panel, setPanel, snap, t } = useOnPage()
261
+ if (panel?.kind !== 'settings') return null
262
+ 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)
265
+ // Row fields: the block's fields for a blocks row, the item fields for an array row.
266
+ const fields =
267
+ owner?.field.type === 'blocks' ? (owner.block?.fields ?? []) : (owner?.field.fields ?? [])
268
+ return (
269
+ <Dialog
270
+ variant="panel"
271
+ title={t('settings.title', { label: panel.title })}
272
+ onClose={() => setPanel(null)}
273
+ t={t}
274
+ testId="onpage-settings"
275
+ >
276
+ <Fields docKey={panel.key} fields={fields} path={panel.path} />
277
+ </Dialog>
278
+ )
279
+ }
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useInsertionEffect, useRef } from 'react'
4
+
5
+ /**
6
+ * A stable function that always calls the latest `fn` (the `useEffectEvent`
7
+ * pattern). Our own, because the React bundled with Next.js 15 doesn't export
8
+ * `useEffectEvent`. Call it from effects and event handlers, not during render.
9
+ */
10
+ export function useEvent<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
11
+ const latest = useRef(fn)
12
+ useInsertionEffect(() => {
13
+ latest.current = fn
14
+ })
15
+ return useCallback((...args: A) => latest.current(...args), [])
16
+ }