@acms-subir/core 0.3.5 → 0.3.7
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/CHANGELOG.md +23 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +148 -2
- package/src/components/DesignManager.tsx +34 -3
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/components/import/ImportExport.tsx +1 -1
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/layouts/Site.astro +5 -4
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +11 -2
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/import/bundle.ts +1 -0
- package/src/lib/import/export.ts +1 -1
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +3 -3
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +104 -14
- package/src/lib/repo/types.ts +22 -2
- package/src/lib/schema.ts +39 -0
- package/src/lib/site-chrome.ts +1 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { actions, isActionError } from 'astro:actions';
|
|
4
|
+
import type { FormDefinition, SavedForm } from '../../lib/forms/schema';
|
|
5
|
+
import FormBuilder from './FormBuilder';
|
|
6
|
+
import { Menu } from './Inspector';
|
|
7
|
+
import { definitionIssue } from './meta';
|
|
8
|
+
|
|
9
|
+
type SaveState = 'saved' | 'dirty' | 'saving' | 'error' | 'invalid' | 'conflict';
|
|
10
|
+
type Props = { form: Pick<SavedForm, 'id' | 'name' | 'kind' | 'purpose' | 'status' | 'revision'> & { hasDraft: boolean }; definition: FormDefinition; responses?: number };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Build page island: the builder plus autosave (1.2s after the last
|
|
14
|
+
* change), Publish, and the header actions (portaled into FormHeader's slot).
|
|
15
|
+
*/
|
|
16
|
+
export default function FormBuildPage({ form, definition, responses = 0 }: Props) {
|
|
17
|
+
const [value, setValue] = useState(definition);
|
|
18
|
+
const [name, setName] = useState(form.name);
|
|
19
|
+
const [status, setStatus] = useState(form.status);
|
|
20
|
+
const [hasDraft, setHasDraft] = useState(form.hasDraft);
|
|
21
|
+
const [state, setState] = useState<SaveState>('saved');
|
|
22
|
+
const [message, setMessage] = useState('');
|
|
23
|
+
const [preview, setPreview] = useState(false);
|
|
24
|
+
const [busy, setBusy] = useState(false);
|
|
25
|
+
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
|
26
|
+
const revision = useRef(form.revision);
|
|
27
|
+
const latest = useRef({ value, name });
|
|
28
|
+
latest.current = { value, name };
|
|
29
|
+
const saved = useRef(JSON.stringify({ value: definition, name: form.name }));
|
|
30
|
+
const inflight = useRef<Promise<boolean> | null>(null);
|
|
31
|
+
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
32
|
+
const template = form.kind === 'template';
|
|
33
|
+
|
|
34
|
+
useEffect(() => { setSlot(document.getElementById('fb-actions')); }, []);
|
|
35
|
+
|
|
36
|
+
const send = useCallback(async (op: 'save' | 'publish' | 'archive'): Promise<boolean> => {
|
|
37
|
+
// One request at a time, so revisions stay in order.
|
|
38
|
+
while (inflight.current) await inflight.current;
|
|
39
|
+
const snapshot = latest.current, json = JSON.stringify(snapshot);
|
|
40
|
+
if (op === 'save' && json === saved.current) { setState('saved'); return true; }
|
|
41
|
+
const issue = definitionIssue(snapshot.value) || (!snapshot.name.trim() ? 'The form needs a name.' : '');
|
|
42
|
+
if (issue) { setState('invalid'); setMessage(issue); return false; }
|
|
43
|
+
setState('saving');
|
|
44
|
+
const run = (async () => {
|
|
45
|
+
const { data, error } = await actions.forms.save({ id: form.id, revision: revision.current, name: snapshot.name.trim(), definition: snapshot.value, op });
|
|
46
|
+
if (error) {
|
|
47
|
+
if (isActionError(error) && error.code === 'CONFLICT') { setState('conflict'); setMessage('This form changed in another window. Reload the page to keep editing.'); }
|
|
48
|
+
else { setState('error'); setMessage(error.message || 'Could not save.'); }
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
revision.current = data.revision;
|
|
52
|
+
saved.current = json;
|
|
53
|
+
setStatus(data.status); setHasDraft(data.hasDraft);
|
|
54
|
+
setState(JSON.stringify(latest.current) === json ? 'saved' : 'dirty');
|
|
55
|
+
return true;
|
|
56
|
+
})();
|
|
57
|
+
inflight.current = run;
|
|
58
|
+
try { return await run; } finally { inflight.current = null; }
|
|
59
|
+
}, [form.id]);
|
|
60
|
+
|
|
61
|
+
// Autosave after a pause in editing.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (state === 'conflict') return;
|
|
64
|
+
if (JSON.stringify({ value, name }) === saved.current) return;
|
|
65
|
+
setState(s => s === 'saving' ? s : 'dirty');
|
|
66
|
+
clearTimeout(timer.current);
|
|
67
|
+
timer.current = setTimeout(() => { void send('save'); }, 1200);
|
|
68
|
+
return () => clearTimeout(timer.current);
|
|
69
|
+
}, [value, name, send]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const warn = (e: BeforeUnloadEvent) => { if (JSON.stringify(latest.current) !== saved.current) e.preventDefault(); };
|
|
73
|
+
addEventListener('beforeunload', warn);
|
|
74
|
+
return () => removeEventListener('beforeunload', warn);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Keep the server-rendered header (name + status) in step.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const h1 = document.querySelector('.fh-row h1'); if (h1 && name.trim()) h1.textContent = name;
|
|
80
|
+
const crumb = document.querySelector('.fh-crumb'); const link = crumb?.querySelector('a');
|
|
81
|
+
if (crumb && link && name.trim()) { crumb.replaceChildren(link, document.createTextNode(` / ${name}`)); }
|
|
82
|
+
}, [name]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const badge = document.querySelector('.fh-status'); if (!badge) return;
|
|
85
|
+
const label = status === 'published' ? (hasDraft ? 'Edited' : 'Published') : status === 'archived' ? 'Archived' : 'Draft';
|
|
86
|
+
const marker = document.createElement('i'); marker.setAttribute('aria-hidden', 'true');
|
|
87
|
+
badge.replaceChildren(marker, document.createTextNode(label));
|
|
88
|
+
badge.className = `fh-status fst ${label === 'Published' ? 'fst-green' : label === 'Edited' ? 'fst-amber' : 'fst-muted'}`;
|
|
89
|
+
}, [status, hasDraft]);
|
|
90
|
+
|
|
91
|
+
const publish = async () => {
|
|
92
|
+
clearTimeout(timer.current); setBusy(true);
|
|
93
|
+
const ok = await send('publish');
|
|
94
|
+
setBusy(false);
|
|
95
|
+
if (ok) setMessage('');
|
|
96
|
+
};
|
|
97
|
+
const archive = async () => {
|
|
98
|
+
if (!window.confirm('Archive this form? It stops accepting responses. Existing responses are kept.')) return;
|
|
99
|
+
clearTimeout(timer.current);
|
|
100
|
+
if (await send('archive')) location.reload();
|
|
101
|
+
};
|
|
102
|
+
const saveAsTemplate = async () => {
|
|
103
|
+
setBusy(true);
|
|
104
|
+
const { data, error } = await actions.forms.saveTemplate({ name: `${name.trim().slice(0, 110)} template`, definition: latest.current.value, purpose: form.purpose });
|
|
105
|
+
setBusy(false);
|
|
106
|
+
if (error) { setState('error'); setMessage(error.message); return; }
|
|
107
|
+
location.href = `/admin/forms/${data.form.id}`;
|
|
108
|
+
};
|
|
109
|
+
const useTemplate = async () => {
|
|
110
|
+
clearTimeout(timer.current);
|
|
111
|
+
if (!(await send('save'))) return;
|
|
112
|
+
setBusy(true);
|
|
113
|
+
const { data, error } = await actions.forms.useTemplate({ id: form.id, name: name.trim().replace(/ template$/i, '') || name });
|
|
114
|
+
setBusy(false);
|
|
115
|
+
if (error) { setState('error'); setMessage(error.message); return; }
|
|
116
|
+
location.href = `/admin/forms/${data.id}`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const live = status === 'published';
|
|
120
|
+
const text = state === 'saving' ? 'Saving…'
|
|
121
|
+
: state === 'dirty' ? 'Unsaved changes'
|
|
122
|
+
: state === 'invalid' ? `Not saved · ${message}`
|
|
123
|
+
: state === 'error' ? `Couldn’t save · ${message}`
|
|
124
|
+
: state === 'conflict' ? message
|
|
125
|
+
: template ? 'Saved'
|
|
126
|
+
: live ? (hasDraft ? 'Unpublished changes' : 'All changes live')
|
|
127
|
+
: status === 'archived' ? 'Saved · archived' : 'Draft saved';
|
|
128
|
+
const tone = state === 'invalid' || state === 'error' || state === 'conflict' ? 'bad' : (state === 'saved' && live && hasDraft) ? 'warn' : '';
|
|
129
|
+
const canPublish = !template && status !== 'archived' && state !== 'conflict' && (!live || hasDraft || state !== 'saved');
|
|
130
|
+
|
|
131
|
+
const headerActions = (
|
|
132
|
+
<div className="fb-actions">
|
|
133
|
+
<span className={`fb-state ${tone}`} role="status" aria-live="polite">{text}</span>
|
|
134
|
+
{state === 'conflict' && <button type="button" className="btn sm" onClick={() => location.reload()}>Reload</button>}
|
|
135
|
+
{state === 'error' && <button type="button" className="btn sm" onClick={() => void send('save')}>Retry</button>}
|
|
136
|
+
<button type="button" className="btn" aria-pressed={preview} onClick={() => setPreview(p => !p)}>{preview ? 'Edit' : 'Preview'}</button>
|
|
137
|
+
{template
|
|
138
|
+
? form.purpose === 'general' && <button type="button" className="btn primary" disabled={busy || state === 'conflict'} onClick={useTemplate}>Use template</button>
|
|
139
|
+
: <button type="button" className="btn primary" disabled={busy || !canPublish} onClick={publish}>{live ? 'Publish changes' : 'Publish'}</button>}
|
|
140
|
+
<Menu items={[
|
|
141
|
+
...(!template && live ? [{ label: 'Open public form ↗', run: () => window.open(`/forms/${form.id}`, '_blank', 'noopener') }] : []),
|
|
142
|
+
{ label: template ? 'Duplicate template' : 'Save as new template', disabled: busy, run: saveAsTemplate },
|
|
143
|
+
...(status !== 'archived' ? [{ label: template ? 'Archive template' : 'Archive form', danger: true, run: archive }] : []),
|
|
144
|
+
]} />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
{slot && createPortal(headerActions, slot)}
|
|
151
|
+
<FormBuilder value={value} onChange={setValue} mode={form.purpose === 'event' ? 'event' : 'standalone'} name={name} onNameChange={setName}
|
|
152
|
+
responses={responses} preview={preview} onPreviewChange={setPreview} />
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { arrayMove } from '@dnd-kit/sortable';
|
|
3
|
+
import { starter, type FormDefinition, type FormField } from '../../lib/forms/schema';
|
|
4
|
+
import { Canvas, Preview } from './Canvas';
|
|
5
|
+
import { Inspector } from './Inspector';
|
|
6
|
+
import { InsertMenu } from './InsertMenu';
|
|
7
|
+
import { Outline } from './Outline';
|
|
8
|
+
import { DEFAULT_EVENT_SYSTEM, META, clean, keyFrom, newField, switchType, type FieldType, type Selection, type SystemField } from './meta';
|
|
9
|
+
import { useFormHistory } from './useFormHistory';
|
|
10
|
+
|
|
11
|
+
export type FormBuilderProps = {
|
|
12
|
+
value: FormDefinition;
|
|
13
|
+
onChange: (v: FormDefinition) => void;
|
|
14
|
+
/** @deprecated use mode="event" */
|
|
15
|
+
event?: boolean;
|
|
16
|
+
mode?: 'standalone' | 'event';
|
|
17
|
+
/** Locked rows shown first (event registration: Name, Email, Seats). Defaults to those in event mode. */
|
|
18
|
+
systemFields?: SystemField[];
|
|
19
|
+
/** Admin-only name, edited with the title (Build page). */
|
|
20
|
+
name?: string;
|
|
21
|
+
onNameChange?: (name: string) => void;
|
|
22
|
+
/** With responses, answer keys of existing questions are locked. */
|
|
23
|
+
responses?: number;
|
|
24
|
+
/** Controlled preview (the Build page drives it from its header). */
|
|
25
|
+
preview?: boolean;
|
|
26
|
+
onPreviewChange?: (on: boolean) => void;
|
|
27
|
+
};
|
|
28
|
+
type Layout = 'wide' | 'mid' | 'phone';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Three-pane form builder: outline · live canvas · inspector. Collapses to
|
|
32
|
+
* canvas + inspector (outline in a drawer) under 1100px of its own width, and
|
|
33
|
+
* to a question list with a bottom-sheet inspector under 760px.
|
|
34
|
+
*/
|
|
35
|
+
export default function FormBuilder({ value, onChange, event = false, mode, systemFields, name, onNameChange, responses = 0, preview: previewProp, onPreviewChange }: FormBuilderProps) {
|
|
36
|
+
const isEvent = mode ? mode === 'event' : event;
|
|
37
|
+
const system = systemFields ?? (isEvent ? DEFAULT_EVENT_SYSTEM : []);
|
|
38
|
+
const history = useFormHistory(value, onChange);
|
|
39
|
+
const { commit } = history;
|
|
40
|
+
const valueRef = useRef(value); valueRef.current = value;
|
|
41
|
+
const [selection, setSelection] = useState<Selection>(null);
|
|
42
|
+
const [inserting, setInserting] = useState<{ after: string | null } | null>(null);
|
|
43
|
+
const [drawer, setDrawer] = useState(false);
|
|
44
|
+
const [sheet, setSheet] = useState(false);
|
|
45
|
+
const [toast, setToast] = useState<{ text: string; undo?: () => void } | null>(null);
|
|
46
|
+
const [previewOwn, setPreviewOwn] = useState(false);
|
|
47
|
+
const preview = previewProp ?? previewOwn;
|
|
48
|
+
const setPreview = (on: boolean) => { if (onPreviewChange) onPreviewChange(on); else setPreviewOwn(on); };
|
|
49
|
+
const [device, setDevice] = useState<'desktop' | 'phone'>('desktop');
|
|
50
|
+
const [layout, setLayout] = useState<Layout>('wide');
|
|
51
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
52
|
+
// Keys of questions that already existed when there were responses stay fixed; label edits keep new questions' keys in sync until a key is typed by hand.
|
|
53
|
+
const lockedKeys = useMemo(() => new Set(responses > 0 ? value.fields.map(f => f.id) : []), [responses]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
54
|
+
const autoKeys = useRef(new Set<string>());
|
|
55
|
+
const optionMemory = useRef(new Map<string, string[]>());
|
|
56
|
+
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
const el = rootRef.current; if (!el) return;
|
|
59
|
+
const measure = (w: number) => setLayout(w >= 1100 ? 'wide' : w >= 760 ? 'mid' : 'phone');
|
|
60
|
+
measure(el.getBoundingClientRect().width);
|
|
61
|
+
const ro = new ResizeObserver(([entry]) => measure(entry.contentRect.width));
|
|
62
|
+
ro.observe(el);
|
|
63
|
+
return () => ro.disconnect();
|
|
64
|
+
}, []);
|
|
65
|
+
useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 6000); return () => clearTimeout(t); }, [toast]);
|
|
66
|
+
// Drop a selection that no longer exists (after undo, for example).
|
|
67
|
+
useEffect(() => { if (selection?.kind === 'field' && !value.fields.some(f => f.id === selection.id)) { setSelection(null); setSheet(false); } }, [value, selection]);
|
|
68
|
+
|
|
69
|
+
const fields = value.fields;
|
|
70
|
+
const setFields = useCallback((next: FormField[], key?: string) => commit({ ...valueRef.current, fields: next }, key), [commit]);
|
|
71
|
+
const patch = useCallback((next: Partial<FormDefinition>, key: string) => commit({ ...valueRef.current, ...next }, key), [commit]);
|
|
72
|
+
const patchField = useCallback((id: string, next: Partial<FormField>, key: string) => {
|
|
73
|
+
setFields(valueRef.current.fields.map(f => f.id === id ? clean({ ...f, ...next }) : f), key);
|
|
74
|
+
}, [setFields]);
|
|
75
|
+
const select = (s: Selection) => { setSelection(s); setDrawer(false); if (layout === 'phone' && s) setSheet(true); };
|
|
76
|
+
|
|
77
|
+
// Focus after React has rendered the moved/new question.
|
|
78
|
+
const [focusTarget, setFocusTarget] = useState<{ id: string } | null>(null);
|
|
79
|
+
const focusItem = (id: string) => setFocusTarget({ id });
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!focusTarget) return;
|
|
82
|
+
const root = rootRef.current;
|
|
83
|
+
const el = root?.querySelector<HTMLElement>(layout === 'phone' ? `[data-field="${CSS.escape(focusTarget.id)}"]` : `[data-item="${CSS.escape(focusTarget.id)}"]`);
|
|
84
|
+
if (el) { el.focus({ preventScroll: true }); el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
|
|
85
|
+
else root?.focus({ preventScroll: true });
|
|
86
|
+
setFocusTarget(null);
|
|
87
|
+
}, [focusTarget, layout]);
|
|
88
|
+
const insert = (type: FieldType, after: string | null) => {
|
|
89
|
+
const current = valueRef.current.fields;
|
|
90
|
+
if (current.length >= 30) return;
|
|
91
|
+
const f = newField(type, current.map(x => x.id));
|
|
92
|
+
autoKeys.current.add(f.id);
|
|
93
|
+
const at = after ? current.findIndex(x => x.id === after) + 1 : current.length;
|
|
94
|
+
const next = [...current]; next.splice(at || current.length, 0, f);
|
|
95
|
+
setFields(next);
|
|
96
|
+
setInserting(null);
|
|
97
|
+
setSelection({ kind: 'field', id: f.id });
|
|
98
|
+
if (layout === 'phone') setSheet(true);
|
|
99
|
+
else focusItem(f.id);
|
|
100
|
+
};
|
|
101
|
+
const actions = {
|
|
102
|
+
move: (id: string, dir: -1 | 1) => {
|
|
103
|
+
const i = valueRef.current.fields.findIndex(f => f.id === id), j = i + dir;
|
|
104
|
+
if (i < 0 || j < 0 || j >= valueRef.current.fields.length) return;
|
|
105
|
+
setFields(arrayMove(valueRef.current.fields, i, j));
|
|
106
|
+
focusItem(id);
|
|
107
|
+
},
|
|
108
|
+
duplicate: (id: string) => {
|
|
109
|
+
const current = valueRef.current.fields, i = current.findIndex(f => f.id === id);
|
|
110
|
+
if (i < 0 || current.length >= 30) return;
|
|
111
|
+
const copy = { ...current[i], options: [...current[i].options], id: keyFrom(current[i].label, current.map(f => f.id)) };
|
|
112
|
+
autoKeys.current.add(copy.id);
|
|
113
|
+
const next = [...current]; next.splice(i + 1, 0, copy);
|
|
114
|
+
setFields(next);
|
|
115
|
+
setSelection({ kind: 'field', id: copy.id });
|
|
116
|
+
focusItem(copy.id);
|
|
117
|
+
},
|
|
118
|
+
remove: (id: string) => {
|
|
119
|
+
const current = valueRef.current.fields, i = current.findIndex(f => f.id === id);
|
|
120
|
+
if (i < 0) return;
|
|
121
|
+
setFields(current.filter(f => f.id !== id));
|
|
122
|
+
const neighbour = current[i + 1] ?? current[i - 1];
|
|
123
|
+
setSelection(neighbour && layout !== 'phone' ? { kind: 'field', id: neighbour.id } : null);
|
|
124
|
+
setSheet(false);
|
|
125
|
+
setToast({ text: `Deleted “${current[i].label}”.`, undo: () => { history.undo(); setSelection({ kind: 'field', id }); } });
|
|
126
|
+
if (neighbour && layout !== 'phone') focusItem(neighbour.id); else rootRef.current?.focus();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const setType = (id: string, type: FieldType) => {
|
|
130
|
+
const f = valueRef.current.fields.find(x => x.id === id); if (!f) return;
|
|
131
|
+
if (f.options.length) optionMemory.current.set(id, f.options);
|
|
132
|
+
setFields(valueRef.current.fields.map(x => x.id === id ? switchType(x, type, optionMemory.current.get(id)) : x));
|
|
133
|
+
};
|
|
134
|
+
const onLabelDone = (id: string) => {
|
|
135
|
+
const f = valueRef.current.fields.find(x => x.id === id); if (!f) return;
|
|
136
|
+
// Never leave a question without a label; keep fresh questions' keys readable.
|
|
137
|
+
const label = f.label.trim() || META[f.type].fresh;
|
|
138
|
+
let nextId = id;
|
|
139
|
+
if (autoKeys.current.has(id) && !lockedKeys.has(id)) {
|
|
140
|
+
nextId = keyFrom(label, valueRef.current.fields.filter(x => x.id !== id).map(x => x.id));
|
|
141
|
+
if (nextId !== id) { autoKeys.current.delete(id); autoKeys.current.add(nextId); }
|
|
142
|
+
}
|
|
143
|
+
if (label === f.label && nextId === id) return;
|
|
144
|
+
setFields(valueRef.current.fields.map(x => x.id === id ? { ...x, label, id: nextId } : x), `label:${id}`);
|
|
145
|
+
if (nextId !== id) setSelection(s => s?.kind === 'field' && s.id === id ? { kind: 'field', id: nextId } : s);
|
|
146
|
+
};
|
|
147
|
+
const setKey = (id: string, key: string) => {
|
|
148
|
+
autoKeys.current.delete(id);
|
|
149
|
+
setFields(valueRef.current.fields.map(x => x.id === id ? { ...x, id: key } : x));
|
|
150
|
+
setSelection({ kind: 'field', id: key });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
154
|
+
if (inserting) return;
|
|
155
|
+
const t = e.target as HTMLElement;
|
|
156
|
+
const typing = t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
|
|
157
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
158
|
+
const fieldId = selection?.kind === 'field' ? selection.id : null;
|
|
159
|
+
if (mod && !typing && e.key.toLowerCase() === 'z') { e.preventDefault(); if (e.shiftKey) history.redo(); else history.undo(); return; }
|
|
160
|
+
if (mod && !typing && e.key.toLowerCase() === 'y') { e.preventDefault(); history.redo(); return; }
|
|
161
|
+
// Question shortcuts only from the canvas/list, never from settings controls.
|
|
162
|
+
if (typing || preview || t.closest('.fb-insp, .fb-sheet, .fb-menu, .fb-bar')) return;
|
|
163
|
+
if (e.key === '/' && !mod) { e.preventDefault(); setInserting({ after: fieldId }); return; }
|
|
164
|
+
if (e.key === 'Escape' && (selection || drawer)) { setDrawer(false); setSelection(null); return; }
|
|
165
|
+
if (!fieldId) return;
|
|
166
|
+
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); actions.move(fieldId, e.key === 'ArrowUp' ? -1 : 1); return; }
|
|
167
|
+
if (mod && e.key.toLowerCase() === 'd') { e.preventDefault(); actions.duplicate(fieldId); return; }
|
|
168
|
+
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); actions.remove(fieldId); return; }
|
|
169
|
+
if (!e.altKey && !mod && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
170
|
+
const i = fields.findIndex(f => f.id === fieldId), next = fields[i + (e.key === 'ArrowUp' ? -1 : 1)];
|
|
171
|
+
if (next) { e.preventDefault(); setSelection({ kind: 'field', id: next.id }); focusItem(next.id); }
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const keyRef = useRef(onKeyDown); keyRef.current = onKeyDown;
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
// If focus fell back to <body> (after a delete, say), shortcuts still reach the builder.
|
|
178
|
+
const onDoc = (e: KeyboardEvent) => {
|
|
179
|
+
if (document.activeElement !== document.body || !rootRef.current?.offsetParent) return;
|
|
180
|
+
keyRef.current({ ...e, target: rootRef.current, key: e.key, metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, preventDefault: () => e.preventDefault() } as unknown as React.KeyboardEvent);
|
|
181
|
+
};
|
|
182
|
+
document.addEventListener('keydown', onDoc);
|
|
183
|
+
return () => document.removeEventListener('keydown', onDoc);
|
|
184
|
+
}, []);
|
|
185
|
+
const openInsert = (after: string | null = selection?.kind === 'field' ? selection.id : null) => { setDrawer(false); setInserting({ after }); };
|
|
186
|
+
const outline = (
|
|
187
|
+
<Outline fields={fields} system={system} selection={selection} onSelect={s => {
|
|
188
|
+
select(s);
|
|
189
|
+
// Picking from the list brings the question into view on the canvas.
|
|
190
|
+
if (s?.kind === 'field' && layout !== 'phone') requestAnimationFrame(() => rootRef.current?.querySelector(`[data-item="${CSS.escape(s.id)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
191
|
+
}} phone={layout === 'phone'}
|
|
192
|
+
onReorder={(from, to) => setFields(arrayMove(valueRef.current.fields, from, to))} onAdd={() => openInsert()} actions={actions}
|
|
193
|
+
labels={{ title: value.title, submit: value.submitLabel || (isEvent ? 'Confirm my seat' : 'Submit') }} />
|
|
194
|
+
);
|
|
195
|
+
const inspector = (
|
|
196
|
+
<Inspector value={value} selection={selection} system={system} event={isEvent} phone={layout === 'phone'} name={name} onNameChange={onNameChange}
|
|
197
|
+
lockedKeys={lockedKeys} patch={patch} patchField={patchField} setType={setType} onLabelDone={onLabelDone} setKey={setKey} actions={actions} onAdd={() => openInsert()} />
|
|
198
|
+
);
|
|
199
|
+
const canvas = preview
|
|
200
|
+
? <Preview value={value} system={system} event={isEvent} device={layout === 'phone' ? 'phone' : device} />
|
|
201
|
+
: <Canvas value={value} system={system} selection={selection} event={isEvent} onSelect={select} patch={patch} patchField={patchField}
|
|
202
|
+
onLabelDone={onLabelDone} onInsertAfter={after => openInsert(after)} onDuplicate={actions.duplicate} onRemove={actions.remove} />;
|
|
203
|
+
|
|
204
|
+
const undoRedo = <>
|
|
205
|
+
<button type="button" className="fb-icon" aria-label="Undo" title="Undo (⌘Z)" disabled={!history.canUndo} onClick={history.undo}>
|
|
206
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" /></svg>
|
|
207
|
+
</button>
|
|
208
|
+
<button type="button" className="fb-icon" aria-label="Redo" title="Redo (⇧⌘Z)" disabled={!history.canRedo} onClick={history.redo}>
|
|
209
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5" /><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13" /></svg>
|
|
210
|
+
</button>
|
|
211
|
+
</>;
|
|
212
|
+
// On the wide Build page the header owns Preview, so undo/redo float on the canvas instead of taking a bar.
|
|
213
|
+
const showBar = layout !== 'wide' || preview || previewProp === undefined;
|
|
214
|
+
return (
|
|
215
|
+
<div ref={rootRef} className={`fb fb-${layout}${preview ? ' is-preview' : ''}`} onKeyDown={onKeyDown} tabIndex={-1}>
|
|
216
|
+
{showBar && <div className="fb-bar">
|
|
217
|
+
{layout === 'mid' && !preview && (
|
|
218
|
+
<button type="button" className="fb-btn sm" aria-expanded={drawer} onClick={() => setDrawer(d => !d)}>
|
|
219
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10" /></svg> Questions <small>{fields.length}</small>
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
{layout === 'phone' && (
|
|
223
|
+
<div className="fb-seg" role="group" aria-label="View">
|
|
224
|
+
<button type="button" aria-pressed={!preview} onClick={() => setPreview(false)}>Questions</button>
|
|
225
|
+
<button type="button" aria-pressed={preview} onClick={() => setPreview(true)}>Preview</button>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
<span className="fb-bar-sp" />
|
|
229
|
+
{preview && layout !== 'phone' && (
|
|
230
|
+
<div className="fb-seg" role="group" aria-label="Preview width">
|
|
231
|
+
<button type="button" aria-pressed={device === 'desktop'} onClick={() => setDevice('desktop')}>Desktop</button>
|
|
232
|
+
<button type="button" aria-pressed={device === 'phone'} onClick={() => setDevice('phone')}>Phone</button>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
{preview && layout !== 'phone' && <button type="button" className="fb-btn sm" onClick={() => setPreview(false)}>Back to editing</button>}
|
|
236
|
+
{!preview && <>
|
|
237
|
+
{undoRedo}
|
|
238
|
+
{previewProp === undefined && layout !== 'phone' && <button type="button" className="fb-btn sm" onClick={() => setPreview(true)}>Preview</button>}
|
|
239
|
+
</>}
|
|
240
|
+
</div>}
|
|
241
|
+
|
|
242
|
+
{layout === 'phone'
|
|
243
|
+
? <div className="fb-main">{preview ? <div className="fb-canvas">{canvas}</div> : outline}</div>
|
|
244
|
+
: (
|
|
245
|
+
<div className="fb-main">
|
|
246
|
+
{!preview && (layout === 'wide' ? outline : drawer && <>
|
|
247
|
+
<div className="fb-drawer-back" onClick={() => setDrawer(false)} />
|
|
248
|
+
<div className="fb-drawer">{outline}</div>
|
|
249
|
+
</>)}
|
|
250
|
+
<div className="fb-canvas" onClick={e => { if (e.target === e.currentTarget) setSelection(null); }}>
|
|
251
|
+
{!showBar && <div className="fb-float">{undoRedo}</div>}
|
|
252
|
+
{canvas}
|
|
253
|
+
</div>
|
|
254
|
+
{!preview && <aside className="fb-insp" aria-label="Question settings">{inspector}</aside>}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{layout === 'phone' && sheet && selection && !preview && (
|
|
259
|
+
<div className="fb-sheet-back" onMouseDown={e => { if (e.target === e.currentTarget) setSheet(false); }}>
|
|
260
|
+
<div className="fb-sheet" role="dialog" aria-modal="true" aria-label="Edit" onKeyDown={e => { if (e.key === 'Escape') { e.stopPropagation(); setSheet(false); } }}>
|
|
261
|
+
<div className="fb-sheet-grab" aria-hidden="true" />
|
|
262
|
+
<div className="fb-sheet-top"><button type="button" className="fb-btn sm primary" onClick={() => setSheet(false)}>Done</button></div>
|
|
263
|
+
<div className="fb-sheet-body">{inspector}</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{inserting && <InsertMenu onPick={type => insert(type, inserting.after)} onClose={() => { setInserting(null); rootRef.current?.focus(); }} />}
|
|
268
|
+
{toast && (
|
|
269
|
+
<div className="fb-toast" role="status">
|
|
270
|
+
<span>{toast.text}</span>
|
|
271
|
+
{toast.undo && <button type="button" onClick={() => { toast.undo?.(); setToast(null); }}>Undo</button>}
|
|
272
|
+
<button type="button" className="fb-toast-x" aria-label="Dismiss" onClick={() => setToast(null)}>✕</button>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function FormEditor({ definition, event = false }: { definition: FormDefinition; event?: boolean }) {
|
|
280
|
+
const [value, setValue] = useState(definition);
|
|
281
|
+
return <><input type="hidden" name="definition" value={JSON.stringify(value)} /><FormBuilder value={value} onChange={setValue} event={event} /></>;
|
|
282
|
+
}
|
|
283
|
+
export { starter };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Shared head for one form: breadcrumb, name, status and the
|
|
3
|
+
// Build · Responses · Share text tabs. Templates only have Build.
|
|
4
|
+
import type { SavedForm } from '../../lib/forms/schema';
|
|
5
|
+
import '../../styles/forms-admin.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
form: SavedForm;
|
|
9
|
+
active: 'build' | 'responses' | 'share';
|
|
10
|
+
responses?: number;
|
|
11
|
+
}
|
|
12
|
+
const { form, active, responses } = Astro.props;
|
|
13
|
+
const base = `/admin/forms/${form.id}`;
|
|
14
|
+
const status = form.status === 'published' ? (form.draft ? 'Edited' : 'Published') : form.status === 'archived' ? 'Archived' : 'Draft';
|
|
15
|
+
const tone = status === 'Published' ? 'fst-green' : status === 'Edited' ? 'fst-amber' : 'fst-muted';
|
|
16
|
+
---
|
|
17
|
+
<div class="fh">
|
|
18
|
+
<p class="sub fh-crumb"><a href={`/admin/forms${form.kind === 'template' ? '?tab=templates' : ''}`}>{form.kind === 'template' ? 'Templates' : 'Forms'}</a> / {form.name}</p>
|
|
19
|
+
<div class="fh-row">
|
|
20
|
+
<h1>{form.name}</h1>
|
|
21
|
+
<span class={`fh-status fst ${tone}`}><i aria-hidden="true" />{status}</span>
|
|
22
|
+
<div class="fh-actions"><slot name="actions" /></div>
|
|
23
|
+
</div>
|
|
24
|
+
{form.kind === 'form' && (
|
|
25
|
+
<nav class="ptabs fh-tabs" aria-label="Form">
|
|
26
|
+
<a href={base} aria-current={active === 'build' ? 'page' : undefined}>Build</a>
|
|
27
|
+
<a href={`${base}/submissions`} aria-current={active === 'responses' ? 'page' : undefined}>Responses{responses !== undefined && <small>{responses}</small>}</a>
|
|
28
|
+
<a href={`${base}/share`} aria-current={active === 'share' ? 'page' : undefined}>Share</a>
|
|
29
|
+
</nav>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { renderControl } from '../../lib/forms/fields';
|
|
3
|
+
import { TypeIcon } from './TypeIcon';
|
|
4
|
+
import { INSERT, META, newField, type FieldType, type Group } from './meta';
|
|
5
|
+
|
|
6
|
+
const GROUPS: Group[] = ['Basics', 'Contact', 'Other'];
|
|
7
|
+
|
|
8
|
+
/** Tally-style "Add question" menu: search, grouped list, ↑↓ ↵ Esc, 1–5 for the basics, and a small preview. */
|
|
9
|
+
export function InsertMenu({ onPick, onClose }: { onPick: (type: FieldType) => void; onClose: () => void }) {
|
|
10
|
+
const [query, setQuery] = useState('');
|
|
11
|
+
const [active, setActive] = useState(0);
|
|
12
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
13
|
+
const items = useMemo(() => {
|
|
14
|
+
const q = query.trim().toLowerCase();
|
|
15
|
+
if (!q) return INSERT;
|
|
16
|
+
// Label matches first (starts-with beats contains), then group/description matches.
|
|
17
|
+
const rank = (t: FieldType) => { const l = META[t].label.toLowerCase(); return l.startsWith(q) ? 0 : l.includes(q) ? 1 : `${META[t].group} ${t} ${META[t].desc}`.toLowerCase().includes(q) ? 2 : 3; };
|
|
18
|
+
return INSERT.filter(t => rank(t) < 3).sort((a, b) => rank(a) - rank(b));
|
|
19
|
+
}, [query]);
|
|
20
|
+
const current = items[Math.min(active, items.length - 1)];
|
|
21
|
+
useEffect(() => { setActive(0); }, [query]);
|
|
22
|
+
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [active]);
|
|
23
|
+
|
|
24
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
25
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (items.length) setActive(i => (i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length); }
|
|
26
|
+
else if (e.key === 'Enter') { e.preventDefault(); if (current) onPick(current); }
|
|
27
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
|
|
28
|
+
else if (!query && /^[1-5]$/.test(e.key)) { e.preventDefault(); onPick(INSERT[Number(e.key) - 1]); }
|
|
29
|
+
};
|
|
30
|
+
const sample = current ? { ...newField(current, []), label: META[current].fresh, options: ['Option A', 'Option B'] } : null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="fb-ins-back" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
34
|
+
<div className="fb-ins" role="dialog" aria-modal="true" aria-label="Add question" onKeyDown={onKey}>
|
|
35
|
+
<div className="fb-ins-search">
|
|
36
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
|
|
37
|
+
<input autoFocus value={query} onChange={e => setQuery(e.target.value)} placeholder="Find a question type…" aria-label="Find a question type" role="combobox" aria-expanded="true" aria-controls="fb-ins-list" aria-activedescendant={current ? `fb-ins-${current}` : undefined} />
|
|
38
|
+
<kbd>esc</kbd>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="fb-ins-list" id="fb-ins-list" role="listbox" ref={listRef}>
|
|
41
|
+
{!items.length && <p className="fb-ins-empty">No question type matches “{query}”.</p>}
|
|
42
|
+
{(query.trim() ? ['Results'] : GROUPS).map(g => {
|
|
43
|
+
const inGroup = g === 'Results' ? items : items.filter(t => META[t].group === g);
|
|
44
|
+
if (!inGroup.length) return null;
|
|
45
|
+
return (
|
|
46
|
+
<div key={g} role="group" aria-label={g}>
|
|
47
|
+
<p className="fb-ins-g">{g}</p>
|
|
48
|
+
{inGroup.map(t => {
|
|
49
|
+
const i = items.indexOf(t), n = INSERT.indexOf(t);
|
|
50
|
+
return (
|
|
51
|
+
<div key={t} id={`fb-ins-${t}`} role="option" aria-selected={i === active} className="fb-ins-item" onMouseMove={() => setActive(i)} onClick={() => onPick(t)}>
|
|
52
|
+
<TypeIcon type={t} />{META[t].label}{!query && n < 5 && <kbd>{n + 1}</kbd>}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</div>
|
|
60
|
+
{current && sample && (
|
|
61
|
+
<div className="fb-ins-pv" aria-live="polite">
|
|
62
|
+
<b>{META[current].label}</b>
|
|
63
|
+
<p>{META[current].desc}</p>
|
|
64
|
+
<div className="fb-paper fb-ins-sample" inert>
|
|
65
|
+
{current === 'heading'
|
|
66
|
+
? <div className="pc-form-heading"><h3>Section title</h3><p>A short description.</p></div>
|
|
67
|
+
: current === 'consent'
|
|
68
|
+
? <div className="pc-bk-check"><input type="checkbox" readOnly /><span>{sample.label}</span></div>
|
|
69
|
+
: <div className="pc-bk-field"><span>{sample.label}</span><div dangerouslySetInnerHTML={{ __html: renderControl(sample, '', `fb-ins-s-${current}`) }} /></div>}
|
|
70
|
+
</div>
|
|
71
|
+
<button type="button" className="fb-btn primary sm" onClick={() => onPick(current)}>Insert <span aria-hidden="true">↵</span></button>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|