@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.
- package/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/OnPageClient.d.ts +18 -0
- package/dist/OnPageClient.d.ts.map +1 -0
- package/dist/OnPageClient.js +51 -0
- package/dist/OnPageClient.js.map +1 -0
- package/dist/annotations.d.ts +55 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +60 -0
- package/dist/annotations.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +19 -0
- package/dist/context.js.map +1 -0
- package/dist/dom.d.ts +42 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +95 -0
- package/dist/dom.js.map +1 -0
- package/dist/editor/FieldHost.d.ts +5 -0
- package/dist/editor/FieldHost.d.ts.map +1 -0
- package/dist/editor/FieldHost.js +36 -0
- package/dist/editor/FieldHost.js.map +1 -0
- package/dist/editor/Ghosts.d.ts +6 -0
- package/dist/editor/Ghosts.d.ts.map +1 -0
- package/dist/editor/Ghosts.js +37 -0
- package/dist/editor/Ghosts.js.map +1 -0
- package/dist/editor/OnPageEditor.d.ts +25 -0
- package/dist/editor/OnPageEditor.d.ts.map +1 -0
- package/dist/editor/OnPageEditor.js +211 -0
- package/dist/editor/OnPageEditor.js.map +1 -0
- package/dist/editor/useGhosts.d.ts +24 -0
- package/dist/editor/useGhosts.d.ts.map +1 -0
- package/dist/editor/useGhosts.js +34 -0
- package/dist/editor/useGhosts.js.map +1 -0
- package/dist/editor/usePageEvents.d.ts +3 -0
- package/dist/editor/usePageEvents.d.ts.map +1 -0
- package/dist/editor/usePageEvents.js +164 -0
- package/dist/editor/usePageEvents.js.map +1 -0
- package/dist/editor-kit.d.ts +19 -0
- package/dist/editor-kit.d.ts.map +1 -0
- package/dist/editor-kit.js +17 -0
- package/dist/editor-kit.js.map +1 -0
- package/dist/editors/CheckboxEditor.d.ts +3 -0
- package/dist/editors/CheckboxEditor.d.ts.map +1 -0
- package/dist/editors/CheckboxEditor.js +10 -0
- package/dist/editors/CheckboxEditor.js.map +1 -0
- package/dist/editors/ColorEditor.d.ts +4 -0
- package/dist/editors/ColorEditor.d.ts.map +1 -0
- package/dist/editors/ColorEditor.js +21 -0
- package/dist/editors/ColorEditor.js.map +1 -0
- package/dist/editors/DateEditor.d.ts +3 -0
- package/dist/editors/DateEditor.d.ts.map +1 -0
- package/dist/editors/DateEditor.js +28 -0
- package/dist/editors/DateEditor.js.map +1 -0
- package/dist/editors/InlineText.d.ts +4 -0
- package/dist/editors/InlineText.d.ts.map +1 -0
- package/dist/editors/InlineText.js +77 -0
- package/dist/editors/InlineText.js.map +1 -0
- package/dist/editors/NumberEditor.d.ts +4 -0
- package/dist/editors/NumberEditor.d.ts.map +1 -0
- package/dist/editors/NumberEditor.js +23 -0
- package/dist/editors/NumberEditor.js.map +1 -0
- package/dist/editors/RelationshipEditor.d.ts +3 -0
- package/dist/editors/RelationshipEditor.d.ts.map +1 -0
- package/dist/editors/RelationshipEditor.js +60 -0
- package/dist/editors/RelationshipEditor.js.map +1 -0
- package/dist/editors/SelectEditor.d.ts +4 -0
- package/dist/editors/SelectEditor.d.ts.map +1 -0
- package/dist/editors/SelectEditor.js +36 -0
- package/dist/editors/SelectEditor.js.map +1 -0
- package/dist/editors/UploadEditor.d.ts +3 -0
- package/dist/editors/UploadEditor.d.ts.map +1 -0
- package/dist/editors/UploadEditor.js +48 -0
- package/dist/editors/UploadEditor.js.map +1 -0
- package/dist/editors/index.d.ts +13 -0
- package/dist/editors/index.d.ts.map +1 -0
- package/dist/editors/index.js +23 -0
- package/dist/editors/index.js.map +1 -0
- package/dist/editors/useDebouncedSearch.d.ts +17 -0
- package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
- package/dist/editors/useDebouncedSearch.js +50 -0
- package/dist/editors/useDebouncedSearch.js.map +1 -0
- package/dist/hub.d.ts +14 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +20 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +83 -0
- package/dist/layout.js.map +1 -0
- package/dist/locators.d.ts +19 -0
- package/dist/locators.d.ts.map +1 -0
- package/dist/locators.js +215 -0
- package/dist/locators.js.map +1 -0
- package/dist/messages.d.ts +100 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +112 -0
- package/dist/messages.js.map +1 -0
- package/dist/registry.d.ts +15 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +29 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderers.d.ts +7 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +12 -0
- package/dist/renderers.js.map +1 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +49 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/styles.d.ts +8 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +122 -0
- package/dist/styles.js.map +1 -0
- package/dist/targets.d.ts +18 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +86 -0
- package/dist/targets.js.map +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/BlockPicker.d.ts +3 -0
- package/dist/ui/BlockPicker.d.ts.map +1 -0
- package/dist/ui/BlockPicker.js +27 -0
- package/dist/ui/BlockPicker.js.map +1 -0
- package/dist/ui/Dialog.d.ts +22 -0
- package/dist/ui/Dialog.d.ts.map +1 -0
- package/dist/ui/Dialog.js +83 -0
- package/dist/ui/Dialog.js.map +1 -0
- package/dist/ui/Dock.d.ts +7 -0
- package/dist/ui/Dock.d.ts.map +1 -0
- package/dist/ui/Dock.js +104 -0
- package/dist/ui/Dock.js.map +1 -0
- package/dist/ui/Overlay.d.ts +2 -0
- package/dist/ui/Overlay.d.ts.map +1 -0
- package/dist/ui/Overlay.js +150 -0
- package/dist/ui/Overlay.js.map +1 -0
- package/dist/ui/PostButton.d.ts +10 -0
- package/dist/ui/PostButton.d.ts.map +1 -0
- package/dist/ui/PostButton.js +7 -0
- package/dist/ui/PostButton.js.map +1 -0
- package/dist/ui/PreviewToolbar.d.ts +15 -0
- package/dist/ui/PreviewToolbar.d.ts.map +1 -0
- package/dist/ui/PreviewToolbar.js +45 -0
- package/dist/ui/PreviewToolbar.js.map +1 -0
- package/dist/ui/SettingsPanel.d.ts +11 -0
- package/dist/ui/SettingsPanel.d.ts.map +1 -0
- package/dist/ui/SettingsPanel.js +130 -0
- package/dist/ui/SettingsPanel.js.map +1 -0
- package/dist/useEvent.d.ts +7 -0
- package/dist/useEvent.d.ts.map +1 -0
- package/dist/useEvent.js +15 -0
- package/dist/useEvent.js.map +1 -0
- package/package.json +71 -4
- package/src/OnPageClient.tsx +67 -0
- package/src/annotations.ts +83 -0
- package/src/context.tsx +23 -0
- package/src/dom.ts +118 -0
- package/src/editor/FieldHost.tsx +66 -0
- package/src/editor/Ghosts.tsx +55 -0
- package/src/editor/OnPageEditor.tsx +315 -0
- package/src/editor/useGhosts.ts +64 -0
- package/src/editor/usePageEvents.ts +161 -0
- package/src/editor-kit.ts +27 -0
- package/src/editors/CheckboxEditor.tsx +31 -0
- package/src/editors/ColorEditor.tsx +64 -0
- package/src/editors/DateEditor.tsx +64 -0
- package/src/editors/InlineText.tsx +106 -0
- package/src/editors/NumberEditor.tsx +69 -0
- package/src/editors/RelationshipEditor.tsx +153 -0
- package/src/editors/SelectEditor.tsx +74 -0
- package/src/editors/UploadEditor.tsx +130 -0
- package/src/editors/index.ts +35 -0
- package/src/editors/useDebouncedSearch.ts +54 -0
- package/src/hub.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout.ts +83 -0
- package/src/locators.ts +210 -0
- package/src/messages.ts +120 -0
- package/src/registry.ts +43 -0
- package/src/renderers.ts +15 -0
- package/src/sanitize.ts +52 -0
- package/src/styles.ts +122 -0
- package/src/targets.ts +101 -0
- package/src/types.ts +132 -0
- package/src/ui/BlockPicker.tsx +49 -0
- package/src/ui/Dialog.tsx +159 -0
- package/src/ui/Dock.tsx +240 -0
- package/src/ui/Overlay.tsx +324 -0
- package/src/ui/PostButton.tsx +26 -0
- package/src/ui/PreviewToolbar.tsx +123 -0
- package/src/ui/SettingsPanel.tsx +279 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|