@xeplr/ui-factory 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +133 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/xeplr-factory.js +98 -0
- package/package.json +59 -0
- package/src/controls.js +238 -0
- package/src/designs/BuilderSample.jsx +117 -0
- package/src/designs/ControlView.jsx +192 -0
- package/src/designs/Palette.jsx +34 -0
- package/src/designs/PropertyPanel.jsx +228 -0
- package/src/designs/ScreenSample.jsx +65 -0
- package/src/designs/factory.css +177 -0
- package/src/designs/index.js +7 -0
- package/src/document.js +215 -0
- package/src/generate.js +167 -0
- package/src/index.js +19 -0
- package/src/model.js +15 -0
- package/src/pages.jsx +42 -0
- package/src/propertyPath.js +35 -0
- package/src/useFactoryBuilder.js +209 -0
- package/src/useFactoryScreen.js +155 -0
- package/src/validateDocument.js +202 -0
- package/src/values.js +148 -0
package/src/controls.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// THE CONTROLS a screen is made of — one entry per type, and nothing about any
|
|
2
|
+
// control lives anywhere else. The palette lists these, the property panel is
|
|
3
|
+
// generated from `properties`, the document checker reads `props`, and the
|
|
4
|
+
// form schema reads `valueType`. Adding a control is adding an entry.
|
|
5
|
+
//
|
|
6
|
+
// Pure data: no React. The renderers are in designs/ControlView.jsx, keyed by
|
|
7
|
+
// the same `type`, so this file also runs in node — the CLI and the tests read
|
|
8
|
+
// it, and so can Claude when it writes a screen.
|
|
9
|
+
//
|
|
10
|
+
// Sizes are FRACTIONS: `w` of the screen's width, `h` of one page's height
|
|
11
|
+
// (width × the screen's aspect). See document.js.
|
|
12
|
+
|
|
13
|
+
/** A dropdown binds `id` and shows `name` — always. */
|
|
14
|
+
export const OPTION_ID = 'id'
|
|
15
|
+
export const OPTION_LABEL = 'name'
|
|
16
|
+
|
|
17
|
+
// ── property panel contract ──────────────────────────────────────────────
|
|
18
|
+
// { key, title, fields: [{ path, label, type, options?, help? }] }
|
|
19
|
+
// The same shape as the BI dashboard's widgetProperties.js, so the two panels
|
|
20
|
+
// can become one. `path` points into the node (`props.label`), and the editor
|
|
21
|
+
// for each `type` is in designs/PropertyPanel.jsx.
|
|
22
|
+
|
|
23
|
+
const FIELD_BASICS = (extra) => ({
|
|
24
|
+
key: 'field',
|
|
25
|
+
title: 'Field',
|
|
26
|
+
fields: [
|
|
27
|
+
{ path: 'props.label', label: 'Label', type: 'text' },
|
|
28
|
+
{ path: 'props.name', label: 'Field name', type: 'text', help: 'The key this value is saved under — letters, digits and _' },
|
|
29
|
+
...(extra || []),
|
|
30
|
+
{ path: 'props.required', label: 'Required', type: 'toggle' }
|
|
31
|
+
]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const PLACEHOLDER = { path: 'props.placeholder', label: 'Placeholder', type: 'text' }
|
|
35
|
+
|
|
36
|
+
export const CONTROLS = {
|
|
37
|
+
text: {
|
|
38
|
+
type: 'text',
|
|
39
|
+
label: 'Text',
|
|
40
|
+
group: 'Inputs',
|
|
41
|
+
input: true,
|
|
42
|
+
valueType: 'string',
|
|
43
|
+
defaultSize: { w: 0.44, h: 0.08 },
|
|
44
|
+
defaults: { label: 'Text' },
|
|
45
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
46
|
+
validation: ['minLength', 'maxLength', 'pattern', 'patternMessage'],
|
|
47
|
+
properties: [
|
|
48
|
+
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }]),
|
|
49
|
+
{
|
|
50
|
+
key: 'validation',
|
|
51
|
+
title: 'Validation',
|
|
52
|
+
fields: [
|
|
53
|
+
{ path: 'props.validation.minLength', label: 'Min length', type: 'number' },
|
|
54
|
+
{ path: 'props.validation.maxLength', label: 'Max length', type: 'number' },
|
|
55
|
+
{ path: 'props.validation.pattern', label: 'Pattern (regex)', type: 'text' },
|
|
56
|
+
{ path: 'props.validation.patternMessage', label: 'Pattern message', type: 'text', help: 'Shown when the pattern does not match' }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
textarea: {
|
|
63
|
+
type: 'textarea',
|
|
64
|
+
label: 'Text area',
|
|
65
|
+
group: 'Inputs',
|
|
66
|
+
input: true,
|
|
67
|
+
valueType: 'string',
|
|
68
|
+
defaultSize: { w: 0.92, h: 0.18 },
|
|
69
|
+
defaults: { label: 'Notes' },
|
|
70
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
71
|
+
validation: ['minLength', 'maxLength'],
|
|
72
|
+
properties: [
|
|
73
|
+
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'textarea' }]),
|
|
74
|
+
{
|
|
75
|
+
key: 'validation',
|
|
76
|
+
title: 'Validation',
|
|
77
|
+
fields: [
|
|
78
|
+
{ path: 'props.validation.minLength', label: 'Min length', type: 'number' },
|
|
79
|
+
{ path: 'props.validation.maxLength', label: 'Max length', type: 'number' }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
number: {
|
|
86
|
+
type: 'number',
|
|
87
|
+
label: 'Number',
|
|
88
|
+
group: 'Inputs',
|
|
89
|
+
input: true,
|
|
90
|
+
valueType: 'number',
|
|
91
|
+
defaultSize: { w: 0.44, h: 0.08 },
|
|
92
|
+
defaults: { label: 'Number' },
|
|
93
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
94
|
+
validation: ['min', 'max', 'integer'],
|
|
95
|
+
properties: [
|
|
96
|
+
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'number' }]),
|
|
97
|
+
{
|
|
98
|
+
key: 'validation',
|
|
99
|
+
title: 'Validation',
|
|
100
|
+
fields: [
|
|
101
|
+
{ path: 'props.validation.min', label: 'Minimum', type: 'number' },
|
|
102
|
+
{ path: 'props.validation.max', label: 'Maximum', type: 'number' },
|
|
103
|
+
{ path: 'props.validation.integer', label: 'Whole numbers only', type: 'toggle' }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
date: {
|
|
110
|
+
type: 'date',
|
|
111
|
+
label: 'Date',
|
|
112
|
+
group: 'Inputs',
|
|
113
|
+
input: true,
|
|
114
|
+
valueType: 'date',
|
|
115
|
+
defaultSize: { w: 0.44, h: 0.08 },
|
|
116
|
+
defaults: { label: 'Date' },
|
|
117
|
+
props: ['name', 'label', 'required', 'default', 'validation'],
|
|
118
|
+
validation: ['min', 'max'],
|
|
119
|
+
properties: [
|
|
120
|
+
FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'date' }]),
|
|
121
|
+
{
|
|
122
|
+
key: 'validation',
|
|
123
|
+
title: 'Validation',
|
|
124
|
+
fields: [
|
|
125
|
+
{ path: 'props.validation.min', label: 'Earliest', type: 'date' },
|
|
126
|
+
{ path: 'props.validation.max', label: 'Latest', type: 'date' }
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
checkbox: {
|
|
133
|
+
type: 'checkbox',
|
|
134
|
+
label: 'Checkbox',
|
|
135
|
+
group: 'Inputs',
|
|
136
|
+
input: true,
|
|
137
|
+
valueType: 'boolean',
|
|
138
|
+
defaultSize: { w: 0.44, h: 0.06 },
|
|
139
|
+
defaults: { label: 'Checkbox', default: false },
|
|
140
|
+
// `required` on a checkbox means it must be ticked — "I accept".
|
|
141
|
+
props: ['name', 'label', 'required', 'default'],
|
|
142
|
+
validation: [],
|
|
143
|
+
properties: [
|
|
144
|
+
FIELD_BASICS([{ path: 'props.default', label: 'Ticked by default', type: 'toggle' }])
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
dropdown: {
|
|
149
|
+
type: 'dropdown',
|
|
150
|
+
label: 'Dropdown',
|
|
151
|
+
group: 'Inputs',
|
|
152
|
+
input: true,
|
|
153
|
+
// Whatever the source's ids are — a table's may be numbers or strings.
|
|
154
|
+
valueType: null,
|
|
155
|
+
defaultSize: { w: 0.44, h: 0.08 },
|
|
156
|
+
defaults: { label: 'Dropdown', placeholder: 'Select…', data: { source: 'static', options: [] } },
|
|
157
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'data'],
|
|
158
|
+
validation: [],
|
|
159
|
+
properties: [
|
|
160
|
+
FIELD_BASICS([PLACEHOLDER]),
|
|
161
|
+
{
|
|
162
|
+
key: 'data',
|
|
163
|
+
title: 'Options',
|
|
164
|
+
fields: [
|
|
165
|
+
{ path: 'props.data', label: 'Options come from', type: 'dataSource' }
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
label: {
|
|
172
|
+
type: 'label',
|
|
173
|
+
label: 'Label',
|
|
174
|
+
group: 'Content',
|
|
175
|
+
input: false,
|
|
176
|
+
defaultSize: { w: 0.92, h: 0.06 },
|
|
177
|
+
defaults: { text: 'Label', variant: 'text' },
|
|
178
|
+
props: ['text', 'variant'],
|
|
179
|
+
validation: [],
|
|
180
|
+
properties: [
|
|
181
|
+
{
|
|
182
|
+
key: 'content',
|
|
183
|
+
title: 'Content',
|
|
184
|
+
fields: [
|
|
185
|
+
{ path: 'props.text', label: 'Text', type: 'textarea' },
|
|
186
|
+
{ path: 'props.variant', label: 'Style', type: 'select', options: [
|
|
187
|
+
{ value: 'heading', label: 'Heading' },
|
|
188
|
+
{ value: 'subheading', label: 'Subheading' },
|
|
189
|
+
{ value: 'text', label: 'Text' }
|
|
190
|
+
] }
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
button: {
|
|
197
|
+
type: 'button',
|
|
198
|
+
label: 'Button',
|
|
199
|
+
group: 'Actions',
|
|
200
|
+
input: false,
|
|
201
|
+
defaultSize: { w: 0.18, h: 0.07 },
|
|
202
|
+
defaults: { label: 'Save', action: 'submit' },
|
|
203
|
+
props: ['label', 'action'],
|
|
204
|
+
validation: [],
|
|
205
|
+
properties: [
|
|
206
|
+
{
|
|
207
|
+
key: 'button',
|
|
208
|
+
title: 'Button',
|
|
209
|
+
fields: [
|
|
210
|
+
{ path: 'props.label', label: 'Label', type: 'text' },
|
|
211
|
+
{ path: 'props.action', label: 'Does', type: 'select', options: [
|
|
212
|
+
{ value: 'submit', label: 'Submit the form' },
|
|
213
|
+
{ value: 'reset', label: 'Reset the form' }
|
|
214
|
+
] }
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
|
|
222
|
+
export const BUTTON_ACTIONS = ['submit', 'reset']
|
|
223
|
+
|
|
224
|
+
/** Palette order: grouped, in declaration order. */
|
|
225
|
+
export function controlGroups(controls = CONTROLS) {
|
|
226
|
+
const groups = []
|
|
227
|
+
Object.values(controls).forEach((c) => {
|
|
228
|
+
let g = groups.find((x) => x.title === c.group)
|
|
229
|
+
if (!g) { g = { title: c.group, controls: [] }; groups.push(g) }
|
|
230
|
+
g.controls.push(c)
|
|
231
|
+
})
|
|
232
|
+
return groups
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A registry with extra or replaced controls, leaving the built-ins untouched. */
|
|
236
|
+
export function withControls(extra, base = CONTROLS) {
|
|
237
|
+
return { ...base, ...extra }
|
|
238
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { XeplrCanvas } from '@xeplr/ui-canvas'
|
|
3
|
+
import ControlView from './ControlView.jsx'
|
|
4
|
+
import Palette, { DRAG_TYPE } from './Palette.jsx'
|
|
5
|
+
import PropertyPanel from './PropertyPanel.jsx'
|
|
6
|
+
|
|
7
|
+
// The builder, drawn: name and Save on top, controls on the left, the screen
|
|
8
|
+
// on the canvas in the middle, the selection's properties on the right.
|
|
9
|
+
// Presentation only — everything comes from useFactoryBuilder.
|
|
10
|
+
//
|
|
11
|
+
// The canvas is @xeplr/ui-canvas in fraction units with pageAspect set to the
|
|
12
|
+
// screen's aspect, which is exactly the geometry ScreenSample renders with — so
|
|
13
|
+
// the builder shows the screen at its real proportions.
|
|
14
|
+
|
|
15
|
+
export default function BuilderSample({ ctrl, renderPreview, className, style }) {
|
|
16
|
+
const { document: doc } = ctrl
|
|
17
|
+
const [preview, setPreview] = useState(false)
|
|
18
|
+
const problemCount = ctrl.validation.errors.length
|
|
19
|
+
|
|
20
|
+
function onDrop(e) {
|
|
21
|
+
const type = e.dataTransfer.getData(DRAG_TYPE) || e.dataTransfer.getData('text/plain')
|
|
22
|
+
const def = ctrl.controls[type]
|
|
23
|
+
if (!def) return
|
|
24
|
+
e.preventDefault()
|
|
25
|
+
const inner = e.currentTarget.querySelector('.xeplr-canvas-inner')
|
|
26
|
+
if (!inner) return ctrl.addControl(type)
|
|
27
|
+
const rect = inner.getBoundingClientRect()
|
|
28
|
+
const pageH = rect.width * doc.aspect
|
|
29
|
+
// The pointer is the control's CENTRE — dropping a wide field by its
|
|
30
|
+
// top-left corner puts it somewhere you did not point at.
|
|
31
|
+
ctrl.addControl(type, {
|
|
32
|
+
x: (e.clientX - rect.left) / rect.width - def.defaultSize.w / 2,
|
|
33
|
+
y: (e.clientY - rect.top) / pageH - def.defaultSize.h / 2
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={'xeplr-factory-builder' + (className ? ' ' + className : '')} style={style}>
|
|
39
|
+
<header className="xeplr-factory-bar">
|
|
40
|
+
<input
|
|
41
|
+
className="xeplr-factory-name"
|
|
42
|
+
value={doc.name}
|
|
43
|
+
onChange={(e) => ctrl.rename(e.target.value)}
|
|
44
|
+
placeholder="Screen name, e.g. New employee"
|
|
45
|
+
aria-label="Screen name"
|
|
46
|
+
/>
|
|
47
|
+
<div className="xeplr-factory-bar-status" aria-live="polite">
|
|
48
|
+
{ctrl.saving ? 'Saving…' : ctrl.dirty ? 'Unsaved changes' : ''}
|
|
49
|
+
</div>
|
|
50
|
+
{problemCount > 0 && (
|
|
51
|
+
<span className={`xeplr-factory-problems${ctrl.showErrors ? ' is-loud' : ''}`} title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
|
|
52
|
+
{problemCount} problem{problemCount === 1 ? '' : 's'}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
{renderPreview && (
|
|
56
|
+
<button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
|
|
57
|
+
{preview ? 'Back to design' : 'Preview'}
|
|
58
|
+
</button>
|
|
59
|
+
)}
|
|
60
|
+
<button type="button" className="xeplr-factory-primary" onClick={ctrl.save} disabled={ctrl.saving}>
|
|
61
|
+
Save
|
|
62
|
+
</button>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
{ctrl.saveError && <div className="xeplr-factory-save-error" role="alert">{ctrl.saveError}</div>}
|
|
66
|
+
{ctrl.showErrors && ctrl.errorsByNode._document && (
|
|
67
|
+
<div className="xeplr-factory-save-error" role="alert">
|
|
68
|
+
{ctrl.errorsByNode._document.map((e, i) => <div key={i}><code>{e.path}</code> {e.message}</div>)}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{preview && renderPreview ? (
|
|
73
|
+
<div className="xeplr-factory-preview">{renderPreview(doc)}</div>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="xeplr-factory-body">
|
|
76
|
+
<Palette groups={ctrl.palette} onAdd={(type) => ctrl.addControl(type)} />
|
|
77
|
+
|
|
78
|
+
<div
|
|
79
|
+
className="xeplr-factory-stage"
|
|
80
|
+
onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
|
|
81
|
+
onDrop={onDrop}
|
|
82
|
+
>
|
|
83
|
+
<XeplrCanvas
|
|
84
|
+
className="xeplr-factory-canvas"
|
|
85
|
+
items={doc.nodes}
|
|
86
|
+
units="fraction"
|
|
87
|
+
pageAspect={doc.aspect}
|
|
88
|
+
features={{ resize: true, marquee: true }}
|
|
89
|
+
selection={ctrl.selected}
|
|
90
|
+
onSelectionChange={ctrl.setSelected}
|
|
91
|
+
onItemChange={ctrl.moveControl}
|
|
92
|
+
minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
|
|
93
|
+
renderItem={(node, { selected }) => (
|
|
94
|
+
<div className={`xeplr-factory-design-node${ctrl.showErrors && ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
|
|
95
|
+
<ControlView node={node} mode="design" />
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
underlay={doc.nodes.length === 0 && (
|
|
99
|
+
<div className="xeplr-factory-empty">Drag controls here from the left</div>
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<PropertyPanel
|
|
105
|
+
node={ctrl.selectedNode}
|
|
106
|
+
control={ctrl.selectedControl}
|
|
107
|
+
selectionCount={ctrl.selected.size}
|
|
108
|
+
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : null}
|
|
109
|
+
tables={ctrl.tables}
|
|
110
|
+
onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
|
|
111
|
+
onRemove={ctrl.removeSelected}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// How each control LOOKS — the one renderer, used both on the builder's
|
|
2
|
+
// canvas (mode 'design': inert, so a press drags it) and on a live screen
|
|
3
|
+
// (mode 'live'). Sharing it is what makes the builder honest: what you place is
|
|
4
|
+
// what the form shows.
|
|
5
|
+
//
|
|
6
|
+
// Presentation only. Values, options and errors arrive as props.
|
|
7
|
+
|
|
8
|
+
const ids = (node) => `xeplr-factory-${node.id}`
|
|
9
|
+
|
|
10
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onAction, disabled }) {
|
|
11
|
+
const View = VIEWS[node.type]
|
|
12
|
+
if (!View) {
|
|
13
|
+
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
14
|
+
}
|
|
15
|
+
const design = mode === 'design'
|
|
16
|
+
return (
|
|
17
|
+
<View
|
|
18
|
+
node={node}
|
|
19
|
+
p={node.props || {}}
|
|
20
|
+
design={design}
|
|
21
|
+
value={value}
|
|
22
|
+
error={design ? null : error}
|
|
23
|
+
options={options || { loading: false, items: [], error: null }}
|
|
24
|
+
onChange={onChange || (() => {})}
|
|
25
|
+
onAction={onAction || (() => {})}
|
|
26
|
+
disabled={design || disabled}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Field({ node, p, error, children, inline }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`}>
|
|
34
|
+
{!inline && (
|
|
35
|
+
<label className="xeplr-factory-label" htmlFor={ids(node)}>
|
|
36
|
+
{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
|
|
37
|
+
</label>
|
|
38
|
+
)}
|
|
39
|
+
{children}
|
|
40
|
+
{error && <div className="xeplr-factory-error" id={`${ids(node)}-error`} role="alert">{error}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inputProps(node, p, error, disabled) {
|
|
46
|
+
return {
|
|
47
|
+
id: ids(node),
|
|
48
|
+
name: p.name,
|
|
49
|
+
disabled,
|
|
50
|
+
required: undefined, // validation is ours, with our messages — not the browser's bubbles
|
|
51
|
+
'aria-invalid': error ? true : undefined,
|
|
52
|
+
'aria-describedby': error ? `${ids(node)}-error` : undefined,
|
|
53
|
+
tabIndex: disabled ? -1 : undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function TextView({ node, p, value, error, onChange, disabled }) {
|
|
58
|
+
return (
|
|
59
|
+
<Field node={node} p={p} error={error}>
|
|
60
|
+
<input
|
|
61
|
+
className="xeplr-factory-input"
|
|
62
|
+
type="text"
|
|
63
|
+
placeholder={p.placeholder || ''}
|
|
64
|
+
value={value ?? ''}
|
|
65
|
+
onChange={(e) => onChange(e.target.value)}
|
|
66
|
+
{...inputProps(node, p, error, disabled)}
|
|
67
|
+
/>
|
|
68
|
+
</Field>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function TextareaView({ node, p, value, error, onChange, disabled }) {
|
|
73
|
+
return (
|
|
74
|
+
<Field node={node} p={p} error={error}>
|
|
75
|
+
<textarea
|
|
76
|
+
className="xeplr-factory-input xeplr-factory-textarea"
|
|
77
|
+
placeholder={p.placeholder || ''}
|
|
78
|
+
value={value ?? ''}
|
|
79
|
+
onChange={(e) => onChange(e.target.value)}
|
|
80
|
+
{...inputProps(node, p, error, disabled)}
|
|
81
|
+
/>
|
|
82
|
+
</Field>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function NumberView({ node, p, value, error, onChange, disabled }) {
|
|
87
|
+
const v = p.validation || {}
|
|
88
|
+
return (
|
|
89
|
+
<Field node={node} p={p} error={error}>
|
|
90
|
+
<input
|
|
91
|
+
className="xeplr-factory-input"
|
|
92
|
+
type="number"
|
|
93
|
+
inputMode={v.integer ? 'numeric' : 'decimal'}
|
|
94
|
+
step={v.integer ? 1 : 'any'}
|
|
95
|
+
placeholder={p.placeholder || ''}
|
|
96
|
+
value={value ?? ''}
|
|
97
|
+
onChange={(e) => onChange(e.target.value)}
|
|
98
|
+
{...inputProps(node, p, error, disabled)}
|
|
99
|
+
/>
|
|
100
|
+
</Field>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function DateView({ node, p, value, error, onChange, disabled }) {
|
|
105
|
+
const v = p.validation || {}
|
|
106
|
+
return (
|
|
107
|
+
<Field node={node} p={p} error={error}>
|
|
108
|
+
<input
|
|
109
|
+
className="xeplr-factory-input"
|
|
110
|
+
type="date"
|
|
111
|
+
min={v.min}
|
|
112
|
+
max={v.max}
|
|
113
|
+
value={value ?? ''}
|
|
114
|
+
onChange={(e) => onChange(e.target.value)}
|
|
115
|
+
{...inputProps(node, p, error, disabled)}
|
|
116
|
+
/>
|
|
117
|
+
</Field>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function CheckboxView({ node, p, value, error, onChange, disabled }) {
|
|
122
|
+
return (
|
|
123
|
+
<Field node={node} p={p} error={error} inline>
|
|
124
|
+
<label className="xeplr-factory-check" htmlFor={ids(node)}>
|
|
125
|
+
<input
|
|
126
|
+
type="checkbox"
|
|
127
|
+
checked={Boolean(value ?? p.default)}
|
|
128
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
129
|
+
{...inputProps(node, p, error, disabled)}
|
|
130
|
+
/>
|
|
131
|
+
<span>{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}</span>
|
|
132
|
+
</label>
|
|
133
|
+
</Field>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function DropdownView({ node, p, design, value, error, options, onChange, disabled }) {
|
|
138
|
+
const data = p.data || {}
|
|
139
|
+
const items = data.source === 'static' ? (data.options || []) : options.items
|
|
140
|
+
return (
|
|
141
|
+
<Field node={node} p={p} error={error || (!design && options.error) || null}>
|
|
142
|
+
<select
|
|
143
|
+
className="xeplr-factory-input xeplr-factory-select"
|
|
144
|
+
value={value === undefined || value === null ? '' : String(value)}
|
|
145
|
+
onChange={(e) => onChange(e.target.value)}
|
|
146
|
+
{...inputProps(node, p, error, disabled || (!design && options.loading))}
|
|
147
|
+
>
|
|
148
|
+
<option value="">{!design && options.loading ? 'Loading…' : (p.placeholder || 'Select…')}</option>
|
|
149
|
+
{!design && items.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
|
|
150
|
+
</select>
|
|
151
|
+
{design && (
|
|
152
|
+
<div className="xeplr-factory-hint">
|
|
153
|
+
{data.source === 'table'
|
|
154
|
+
? (data.table ? `from table ${data.table}` : 'no table chosen')
|
|
155
|
+
: `${(data.options || []).length} option${(data.options || []).length === 1 ? '' : 's'}`}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</Field>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function LabelView({ p }) {
|
|
163
|
+
const variant = p.variant || 'text'
|
|
164
|
+
const Tag = variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'
|
|
165
|
+
return <Tag className={`xeplr-factory-text xeplr-factory-text--${variant}`}>{p.text}</Tag>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ButtonView({ p, design, onAction, disabled }) {
|
|
169
|
+
const action = p.action || 'submit'
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
className={`xeplr-factory-button xeplr-factory-button--${action}`}
|
|
173
|
+
type={design ? 'button' : action === 'reset' ? 'button' : 'submit'}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
tabIndex={design ? -1 : undefined}
|
|
176
|
+
onClick={action === 'reset' && !design ? () => onAction('reset') : undefined}
|
|
177
|
+
>
|
|
178
|
+
{p.label}
|
|
179
|
+
</button>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const VIEWS = {
|
|
184
|
+
text: TextView,
|
|
185
|
+
textarea: TextareaView,
|
|
186
|
+
number: NumberView,
|
|
187
|
+
date: DateView,
|
|
188
|
+
checkbox: CheckboxView,
|
|
189
|
+
dropdown: DropdownView,
|
|
190
|
+
label: LabelView,
|
|
191
|
+
button: ButtonView
|
|
192
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The controls you can put on a screen. Drag one onto the canvas to place it
|
|
2
|
+
// where it lands, or click it to add it below everything else.
|
|
3
|
+
|
|
4
|
+
export const DRAG_TYPE = 'application/x-xeplr-factory-control'
|
|
5
|
+
|
|
6
|
+
export default function Palette({ groups, onAdd }) {
|
|
7
|
+
return (
|
|
8
|
+
<nav className="xeplr-factory-palette" aria-label="Controls">
|
|
9
|
+
{groups.map((g) => (
|
|
10
|
+
<section key={g.title} className="xeplr-factory-palette-group">
|
|
11
|
+
<h4 className="xeplr-factory-palette-title">{g.title}</h4>
|
|
12
|
+
{g.controls.map((c) => (
|
|
13
|
+
<button
|
|
14
|
+
key={c.type}
|
|
15
|
+
type="button"
|
|
16
|
+
className="xeplr-factory-palette-item"
|
|
17
|
+
draggable
|
|
18
|
+
onDragStart={(e) => {
|
|
19
|
+
e.dataTransfer.setData(DRAG_TYPE, c.type)
|
|
20
|
+
e.dataTransfer.setData('text/plain', c.type)
|
|
21
|
+
e.dataTransfer.effectAllowed = 'copy'
|
|
22
|
+
}}
|
|
23
|
+
onClick={() => onAdd(c.type)}
|
|
24
|
+
title={`Drag onto the screen, or click to add ${c.label.toLowerCase()} at the bottom`}
|
|
25
|
+
>
|
|
26
|
+
<span className={`xeplr-factory-palette-icon xeplr-factory-palette-icon--${c.type}`} aria-hidden="true" />
|
|
27
|
+
{c.label}
|
|
28
|
+
</button>
|
|
29
|
+
))}
|
|
30
|
+
</section>
|
|
31
|
+
))}
|
|
32
|
+
</nav>
|
|
33
|
+
)
|
|
34
|
+
}
|