@xeplr/ui-factory 1.0.1 → 1.0.2
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 +100 -12
- package/README.md +69 -22
- package/bin/xeplr-factory.js +67 -1
- package/package.json +6 -2
- package/src/controls.js +142 -45
- package/src/designs/BuilderSample.jsx +60 -36
- package/src/designs/ControlView.jsx +43 -36
- package/src/designs/ListView.jsx +95 -0
- package/src/designs/PropertyPanel.jsx +187 -13
- package/src/designs/ScreenModal.jsx +42 -0
- package/src/designs/ScreenSample.jsx +49 -9
- package/src/designs/factory.css +68 -20
- package/src/designs/index.js +2 -0
- package/src/designs/styles.js +59 -0
- package/src/document.js +31 -4
- package/src/generate.js +136 -25
- package/src/index.js +2 -2
- package/src/model.js +11 -5
- package/src/pages.jsx +60 -10
- package/src/remote.js +57 -0
- package/src/scaffold.js +73 -0
- package/src/tableSchema.js +225 -0
- package/src/useFactoryBuilder.js +91 -17
- package/src/useFactoryScreen.js +265 -78
- package/src/validateDocument.js +73 -5
- package/src/values.js +61 -0
package/src/controls.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// THE CONTROLS a screen is made of — one entry per type, and nothing about any
|
|
2
2
|
// control lives anywhere else. The palette lists these, the property panel is
|
|
3
|
-
// generated from `properties`, the document checker reads `props`,
|
|
4
|
-
// form schema reads `valueType`. Adding a control is
|
|
3
|
+
// generated from `properties`, the document checker reads `props`, `validation`
|
|
4
|
+
// and `styles`, and the form schema reads `valueType`. Adding a control is
|
|
5
|
+
// adding an entry.
|
|
5
6
|
//
|
|
6
7
|
// Pure data: no React. The renderers are in designs/ControlView.jsx, keyed by
|
|
7
8
|
// the same `type`, so this file also runs in node — the CLI and the tests read
|
|
@@ -14,6 +15,73 @@
|
|
|
14
15
|
export const OPTION_ID = 'id'
|
|
15
16
|
export const OPTION_LABEL = 'name'
|
|
16
17
|
|
|
18
|
+
// ── styles ───────────────────────────────────────────────────────────────
|
|
19
|
+
// Every look a control can have is a key under `props.style`, described once
|
|
20
|
+
// here: its editor, and the values it accepts. Sizes are PIXELS at the screen's
|
|
21
|
+
// design width; a screen shown narrower shrinks them in proportion, and never
|
|
22
|
+
// enlarges them.
|
|
23
|
+
|
|
24
|
+
export const FONT_FAMILIES = [
|
|
25
|
+
{ value: 'system-ui, -apple-system, "Segoe UI", sans-serif', label: 'System' },
|
|
26
|
+
{ value: 'Arial, Helvetica, sans-serif', label: 'Arial' },
|
|
27
|
+
{ value: 'Verdana, Geneva, sans-serif', label: 'Verdana' },
|
|
28
|
+
{ value: 'Tahoma, Geneva, sans-serif', label: 'Tahoma' },
|
|
29
|
+
{ value: '"Trebuchet MS", sans-serif', label: 'Trebuchet MS' },
|
|
30
|
+
{ value: 'Georgia, serif', label: 'Georgia' },
|
|
31
|
+
{ value: '"Times New Roman", Times, serif', label: 'Times New Roman' },
|
|
32
|
+
{ value: '"Courier New", Courier, monospace', label: 'Courier New' },
|
|
33
|
+
{ value: 'ui-monospace, SFMono-Regular, Menlo, monospace', label: 'Monospace' }
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const WEIGHTS = [
|
|
37
|
+
{ value: 300, label: 'Light' },
|
|
38
|
+
{ value: 400, label: 'Regular' },
|
|
39
|
+
{ value: 500, label: 'Medium' },
|
|
40
|
+
{ value: 600, label: 'Semibold' },
|
|
41
|
+
{ value: 700, label: 'Bold' },
|
|
42
|
+
{ value: 800, label: 'Extra bold' }
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* key → { label, type (editor), check }. `check` is what the document checker
|
|
47
|
+
* accepts; `hint` is what it says when a value is refused.
|
|
48
|
+
*/
|
|
49
|
+
export const STYLE_KEYS = {
|
|
50
|
+
fontFamily: { label: 'Font', type: 'font', hint: 'a CSS font-family, e.g. "Georgia, serif"' },
|
|
51
|
+
fontSize: { label: 'Font size (px)', type: 'number', min: 8, max: 96, hint: 'a number of pixels from 8 to 96' },
|
|
52
|
+
fontWeight: { label: 'Weight', type: 'select', options: WEIGHTS, hint: 'one of 300, 400, 500, 600, 700, 800' },
|
|
53
|
+
fontStyle: { label: 'Italic', type: 'toggleValue', on: 'italic', hint: '"normal" or "italic"' },
|
|
54
|
+
textAlign: { label: 'Align', type: 'select', options: [
|
|
55
|
+
{ value: 'left', label: 'Left' }, { value: 'center', label: 'Centre' }, { value: 'right', label: 'Right' }
|
|
56
|
+
], hint: '"left", "center" or "right"' },
|
|
57
|
+
color: { label: 'Text colour', type: 'color', hint: 'a colour as #rgb or #rrggbb' },
|
|
58
|
+
background: { label: 'Background', type: 'color', hint: 'a colour as #rgb or #rrggbb, or "transparent"' },
|
|
59
|
+
borderColor: { label: 'Border colour', type: 'color', hint: 'a colour as #rgb or #rrggbb, or "transparent"' },
|
|
60
|
+
borderWidth: { label: 'Border width (px)', type: 'number', min: 0, max: 10, hint: 'a number of pixels from 0 to 10' },
|
|
61
|
+
borderRadius: { label: 'Corner radius (px)', type: 'number', min: 0, max: 40, hint: 'a number of pixels from 0 to 40' },
|
|
62
|
+
labelFontSize: { label: 'Label size (px)', type: 'number', min: 8, max: 48, hint: 'a number of pixels from 8 to 48' },
|
|
63
|
+
labelFontWeight: { label: 'Label weight', type: 'select', options: WEIGHTS, hint: 'one of 300, 400, 500, 600, 700, 800' },
|
|
64
|
+
labelColor: { label: 'Label colour', type: 'color', hint: 'a colour as #rgb or #rrggbb' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The screen-wide defaults every control inherits. */
|
|
68
|
+
export const SCREEN_STYLE_KEYS = ['fontFamily', 'fontSize', 'color', 'background']
|
|
69
|
+
|
|
70
|
+
const INPUT_STYLES = ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textAlign', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius', 'labelFontSize', 'labelFontWeight', 'labelColor']
|
|
71
|
+
|
|
72
|
+
/** Property-panel groups for a control's styles, split the way people look for them. */
|
|
73
|
+
function styleGroups(keys) {
|
|
74
|
+
const field = (k) => ({ path: `props.style.${k}`, label: STYLE_KEYS[k].label, type: STYLE_KEYS[k].type, options: STYLE_KEYS[k].options, on: STYLE_KEYS[k].on, min: STYLE_KEYS[k].min, max: STYLE_KEYS[k].max })
|
|
75
|
+
const groups = [
|
|
76
|
+
{ key: 'text', title: 'Text', keys: ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textAlign', 'color'] },
|
|
77
|
+
{ key: 'box', title: 'Box', keys: ['background', 'borderColor', 'borderWidth', 'borderRadius'] },
|
|
78
|
+
{ key: 'labelStyle', title: 'Label', keys: ['labelFontSize', 'labelFontWeight', 'labelColor'] }
|
|
79
|
+
]
|
|
80
|
+
return groups
|
|
81
|
+
.map((g) => ({ key: g.key, title: g.title, fields: g.keys.filter((k) => keys.includes(k)).map(field) }))
|
|
82
|
+
.filter((g) => g.fields.length)
|
|
83
|
+
}
|
|
84
|
+
|
|
17
85
|
// ── property panel contract ──────────────────────────────────────────────
|
|
18
86
|
// { key, title, fields: [{ path, label, type, options?, help? }] }
|
|
19
87
|
// The same shape as the BI dashboard's widgetProperties.js, so the two panels
|
|
@@ -42,8 +110,9 @@ export const CONTROLS = {
|
|
|
42
110
|
valueType: 'string',
|
|
43
111
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
44
112
|
defaults: { label: 'Text' },
|
|
45
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
113
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
46
114
|
validation: ['minLength', 'maxLength', 'pattern', 'patternMessage'],
|
|
115
|
+
styles: INPUT_STYLES,
|
|
47
116
|
properties: [
|
|
48
117
|
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }]),
|
|
49
118
|
{
|
|
@@ -55,7 +124,8 @@ export const CONTROLS = {
|
|
|
55
124
|
{ path: 'props.validation.pattern', label: 'Pattern (regex)', type: 'text' },
|
|
56
125
|
{ path: 'props.validation.patternMessage', label: 'Pattern message', type: 'text', help: 'Shown when the pattern does not match' }
|
|
57
126
|
]
|
|
58
|
-
}
|
|
127
|
+
},
|
|
128
|
+
...styleGroups(INPUT_STYLES)
|
|
59
129
|
]
|
|
60
130
|
},
|
|
61
131
|
|
|
@@ -67,8 +137,9 @@ export const CONTROLS = {
|
|
|
67
137
|
valueType: 'string',
|
|
68
138
|
defaultSize: { w: 0.92, h: 0.18 },
|
|
69
139
|
defaults: { label: 'Notes' },
|
|
70
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
140
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
71
141
|
validation: ['minLength', 'maxLength'],
|
|
142
|
+
styles: INPUT_STYLES,
|
|
72
143
|
properties: [
|
|
73
144
|
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'textarea' }]),
|
|
74
145
|
{
|
|
@@ -78,7 +149,8 @@ export const CONTROLS = {
|
|
|
78
149
|
{ path: 'props.validation.minLength', label: 'Min length', type: 'number' },
|
|
79
150
|
{ path: 'props.validation.maxLength', label: 'Max length', type: 'number' }
|
|
80
151
|
]
|
|
81
|
-
}
|
|
152
|
+
},
|
|
153
|
+
...styleGroups(INPUT_STYLES)
|
|
82
154
|
]
|
|
83
155
|
},
|
|
84
156
|
|
|
@@ -90,8 +162,9 @@ export const CONTROLS = {
|
|
|
90
162
|
valueType: 'number',
|
|
91
163
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
92
164
|
defaults: { label: 'Number' },
|
|
93
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
|
|
165
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
94
166
|
validation: ['min', 'max', 'integer'],
|
|
167
|
+
styles: INPUT_STYLES,
|
|
95
168
|
properties: [
|
|
96
169
|
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'number' }]),
|
|
97
170
|
{
|
|
@@ -102,7 +175,8 @@ export const CONTROLS = {
|
|
|
102
175
|
{ path: 'props.validation.max', label: 'Maximum', type: 'number' },
|
|
103
176
|
{ path: 'props.validation.integer', label: 'Whole numbers only', type: 'toggle' }
|
|
104
177
|
]
|
|
105
|
-
}
|
|
178
|
+
},
|
|
179
|
+
...styleGroups(INPUT_STYLES)
|
|
106
180
|
]
|
|
107
181
|
},
|
|
108
182
|
|
|
@@ -114,8 +188,9 @@ export const CONTROLS = {
|
|
|
114
188
|
valueType: 'date',
|
|
115
189
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
116
190
|
defaults: { label: 'Date' },
|
|
117
|
-
props: ['name', 'label', 'required', 'default', 'validation'],
|
|
191
|
+
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
118
192
|
validation: ['min', 'max'],
|
|
193
|
+
styles: INPUT_STYLES,
|
|
119
194
|
properties: [
|
|
120
195
|
FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'date' }]),
|
|
121
196
|
{
|
|
@@ -125,7 +200,8 @@ export const CONTROLS = {
|
|
|
125
200
|
{ path: 'props.validation.min', label: 'Earliest', type: 'date' },
|
|
126
201
|
{ path: 'props.validation.max', label: 'Latest', type: 'date' }
|
|
127
202
|
]
|
|
128
|
-
}
|
|
203
|
+
},
|
|
204
|
+
...styleGroups(INPUT_STYLES)
|
|
129
205
|
]
|
|
130
206
|
},
|
|
131
207
|
|
|
@@ -135,13 +211,15 @@ export const CONTROLS = {
|
|
|
135
211
|
group: 'Inputs',
|
|
136
212
|
input: true,
|
|
137
213
|
valueType: 'boolean',
|
|
138
|
-
defaultSize: { w: 0.44, h: 0.
|
|
214
|
+
defaultSize: { w: 0.44, h: 0.05 },
|
|
139
215
|
defaults: { label: 'Checkbox', default: false },
|
|
140
216
|
// `required` on a checkbox means it must be ticked — "I accept".
|
|
141
|
-
props: ['name', 'label', 'required', 'default'],
|
|
217
|
+
props: ['name', 'label', 'required', 'default', 'style'],
|
|
142
218
|
validation: [],
|
|
219
|
+
styles: ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'color'],
|
|
143
220
|
properties: [
|
|
144
|
-
FIELD_BASICS([{ path: 'props.default', label: 'Ticked by default', type: 'toggle' }])
|
|
221
|
+
FIELD_BASICS([{ path: 'props.default', label: 'Ticked by default', type: 'toggle' }]),
|
|
222
|
+
...styleGroups(['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'color'])
|
|
145
223
|
]
|
|
146
224
|
},
|
|
147
225
|
|
|
@@ -154,8 +232,9 @@ export const CONTROLS = {
|
|
|
154
232
|
valueType: null,
|
|
155
233
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
156
234
|
defaults: { label: 'Dropdown', placeholder: 'Select…', data: { source: 'static', options: [] } },
|
|
157
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'data'],
|
|
235
|
+
props: ['name', 'label', 'placeholder', 'required', 'default', 'data', 'style'],
|
|
158
236
|
validation: [],
|
|
237
|
+
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
159
238
|
properties: [
|
|
160
239
|
FIELD_BASICS([PLACEHOLDER]),
|
|
161
240
|
{
|
|
@@ -164,7 +243,8 @@ export const CONTROLS = {
|
|
|
164
243
|
fields: [
|
|
165
244
|
{ path: 'props.data', label: 'Options come from', type: 'dataSource' }
|
|
166
245
|
]
|
|
167
|
-
}
|
|
246
|
+
},
|
|
247
|
+
...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
|
|
168
248
|
]
|
|
169
249
|
},
|
|
170
250
|
|
|
@@ -173,53 +253,70 @@ export const CONTROLS = {
|
|
|
173
253
|
label: 'Label',
|
|
174
254
|
group: 'Content',
|
|
175
255
|
input: false,
|
|
176
|
-
defaultSize: { w: 0.92, h: 0.
|
|
256
|
+
defaultSize: { w: 0.92, h: 0.05 },
|
|
177
257
|
defaults: { text: 'Label', variant: 'text' },
|
|
178
|
-
props: ['text', 'variant'],
|
|
258
|
+
props: ['text', 'variant', 'style'],
|
|
179
259
|
validation: [],
|
|
260
|
+
styles: ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textAlign', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
180
261
|
properties: [
|
|
181
262
|
{
|
|
182
263
|
key: 'content',
|
|
183
264
|
title: 'Content',
|
|
184
265
|
fields: [
|
|
185
266
|
{ path: 'props.text', label: 'Text', type: 'textarea' },
|
|
186
|
-
{ path: 'props.variant', label: '
|
|
267
|
+
{ path: 'props.variant', label: 'Preset', type: 'select', options: [
|
|
187
268
|
{ value: 'heading', label: 'Heading' },
|
|
188
269
|
{ value: 'subheading', label: 'Subheading' },
|
|
189
270
|
{ 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
|
-
] }
|
|
271
|
+
], help: 'A starting size and weight — the Text settings below override it' }
|
|
215
272
|
]
|
|
216
|
-
}
|
|
273
|
+
},
|
|
274
|
+
...styleGroups(['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textAlign', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
217
275
|
]
|
|
218
276
|
}
|
|
219
277
|
}
|
|
220
278
|
|
|
279
|
+
/** What a list's rows can offer. */
|
|
280
|
+
export const LIST_ACTIONS = ['new', 'edit', 'delete']
|
|
281
|
+
|
|
282
|
+
CONTROLS.list = {
|
|
283
|
+
type: 'list',
|
|
284
|
+
label: 'List',
|
|
285
|
+
group: 'Data',
|
|
286
|
+
input: false,
|
|
287
|
+
defaultSize: { w: 0.92, h: 0.42 },
|
|
288
|
+
// No columns → the screen's own fields, in reading order.
|
|
289
|
+
defaults: { title: 'Saved records', pageSize: 10, actions: ['new', 'edit', 'delete'] },
|
|
290
|
+
// editScreen: the id of the screen Edit and New open in a POPUP. Without one,
|
|
291
|
+
// Edit opens the row in this screen's own fields (a list on a form).
|
|
292
|
+
props: ['title', 'source', 'editScreen', 'columns', 'pageSize', 'actions', 'style'],
|
|
293
|
+
validation: [],
|
|
294
|
+
styles: ['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
|
|
295
|
+
properties: [
|
|
296
|
+
{
|
|
297
|
+
key: 'list',
|
|
298
|
+
title: 'List',
|
|
299
|
+
fields: [
|
|
300
|
+
{ path: 'props.title', label: 'Title', type: 'text' },
|
|
301
|
+
{ path: 'props.source', label: 'Records from', type: 'table', help: 'Blank → the table this screen saves to' },
|
|
302
|
+
{ path: 'props.editScreen', label: 'Edit in', type: 'screen', help: 'The screen Edit and New open in a popup' },
|
|
303
|
+
{ path: 'props.pageSize', label: 'Rows per page', type: 'number' }
|
|
304
|
+
]
|
|
305
|
+
},
|
|
306
|
+
{ key: 'columns', title: 'Columns', fields: [{ path: 'props.columns', label: 'Columns', type: 'columns' }] },
|
|
307
|
+
{ key: 'actions', title: 'Actions', fields: [{ path: 'props.actions', label: 'Actions', type: 'actions' }] },
|
|
308
|
+
...styleGroups(['fontFamily', 'fontSize', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
|
|
221
312
|
export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
|
|
222
|
-
|
|
313
|
+
|
|
314
|
+
/** The sizes a label preset starts from, in px at the design width. */
|
|
315
|
+
export const LABEL_PRESETS = {
|
|
316
|
+
heading: { fontSize: 26, fontWeight: 700 },
|
|
317
|
+
subheading: { fontSize: 18, fontWeight: 600 },
|
|
318
|
+
text: { fontSize: 14, fontWeight: 400 }
|
|
319
|
+
}
|
|
223
320
|
|
|
224
321
|
/** Palette order: grouped, in declaration order. */
|
|
225
322
|
export function controlGroups(controls = CONTROLS) {
|
|
@@ -3,14 +3,25 @@ import { XeplrCanvas } from '@xeplr/ui-canvas'
|
|
|
3
3
|
import ControlView from './ControlView.jsx'
|
|
4
4
|
import Palette, { DRAG_TYPE } from './Palette.jsx'
|
|
5
5
|
import PropertyPanel from './PropertyPanel.jsx'
|
|
6
|
+
import { screenStyle } from './styles.js'
|
|
6
7
|
|
|
7
|
-
// The builder, drawn: name and
|
|
8
|
-
// on the canvas in the middle,
|
|
9
|
-
//
|
|
8
|
+
// The builder, drawn: name and save status on top, controls on the left, the
|
|
9
|
+
// screen on the canvas in the middle, properties on the right — the selected
|
|
10
|
+
// control's, or the screen's own when nothing is selected. Presentation only —
|
|
11
|
+
// everything comes from useFactoryBuilder.
|
|
10
12
|
//
|
|
11
|
-
// The canvas is
|
|
12
|
-
// screen's aspect
|
|
13
|
-
// the builder shows the screen at its real
|
|
13
|
+
// The canvas is exactly the screen's design width (never wider), in fraction
|
|
14
|
+
// units with pageAspect = the screen's aspect: the same geometry ScreenSample
|
|
15
|
+
// renders with, so the builder shows the screen at its real size.
|
|
16
|
+
|
|
17
|
+
const STATUS = {
|
|
18
|
+
idle: '',
|
|
19
|
+
pending: 'Unsaved changes…',
|
|
20
|
+
saving: 'Saving…',
|
|
21
|
+
saved: 'All changes saved',
|
|
22
|
+
invalid: 'Not saved — fix the problems',
|
|
23
|
+
error: 'Not saved'
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export default function BuilderSample({ ctrl, renderPreview, className, style }) {
|
|
16
27
|
const { document: doc } = ctrl
|
|
@@ -44,28 +55,34 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
44
55
|
placeholder="Screen name, e.g. New employee"
|
|
45
56
|
aria-label="Screen name"
|
|
46
57
|
/>
|
|
47
|
-
<div className=
|
|
48
|
-
{ctrl.
|
|
58
|
+
<div className={`xeplr-factory-bar-status xeplr-factory-bar-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
59
|
+
{ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]}
|
|
49
60
|
</div>
|
|
50
61
|
{problemCount > 0 && (
|
|
51
|
-
<span className=
|
|
62
|
+
<span className="xeplr-factory-problems" title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
|
|
52
63
|
{problemCount} problem{problemCount === 1 ? '' : 's'}
|
|
53
64
|
</span>
|
|
54
65
|
)}
|
|
66
|
+
{ctrl.status === 'error' && (
|
|
67
|
+
<button type="button" className="xeplr-factory-secondary" onClick={ctrl.save}>Retry</button>
|
|
68
|
+
)}
|
|
55
69
|
{renderPreview && (
|
|
56
70
|
<button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
|
|
57
71
|
{preview ? 'Back to design' : 'Preview'}
|
|
58
72
|
</button>
|
|
59
73
|
)}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
{ctrl.canPublish && (
|
|
75
|
+
<button type="button" className="xeplr-factory-primary" onClick={ctrl.publish} disabled={ctrl.publishing}>
|
|
76
|
+
{ctrl.publishing ? 'Publishing…' : 'Publish'}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
63
79
|
</header>
|
|
64
80
|
|
|
65
|
-
{ctrl.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
{ctrl.
|
|
81
|
+
{ctrl.publishResult && (
|
|
82
|
+
<div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
|
|
83
|
+
<span>{ctrl.publishResult.message}</span>
|
|
84
|
+
{ctrl.publishResult.detail && <pre className="xeplr-factory-publish-detail">{ctrl.publishResult.detail}</pre>}
|
|
85
|
+
<button type="button" className="xeplr-factory-icon-button" aria-label="Dismiss" onClick={ctrl.clearPublishResult}>×</button>
|
|
69
86
|
</div>
|
|
70
87
|
)}
|
|
71
88
|
|
|
@@ -80,34 +97,41 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
80
97
|
onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
|
|
81
98
|
onDrop={onDrop}
|
|
82
99
|
>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
<div className="xeplr-factory-frame" style={{ maxWidth: doc.width }}>
|
|
101
|
+
<XeplrCanvas
|
|
102
|
+
className="xeplr-factory-canvas"
|
|
103
|
+
style={screenStyle(doc)}
|
|
104
|
+
items={doc.nodes}
|
|
105
|
+
units="fraction"
|
|
106
|
+
pageAspect={doc.aspect}
|
|
107
|
+
features={{ resize: true, marquee: true }}
|
|
108
|
+
selection={ctrl.selected}
|
|
109
|
+
onSelectionChange={ctrl.setSelected}
|
|
110
|
+
onItemChange={ctrl.moveControl}
|
|
111
|
+
minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
|
|
112
|
+
renderItem={(node, { selected }) => (
|
|
113
|
+
<div className={`xeplr-factory-design-node${ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
|
|
114
|
+
<ControlView node={node} mode="design" list={node.type === 'list' ? { doc } : undefined} />
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
underlay={doc.nodes.length === 0 && (
|
|
118
|
+
<div className="xeplr-factory-empty">Drag controls here from the left</div>
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
102
122
|
</div>
|
|
103
123
|
|
|
104
124
|
<PropertyPanel
|
|
125
|
+
doc={doc}
|
|
105
126
|
node={ctrl.selectedNode}
|
|
106
127
|
control={ctrl.selectedControl}
|
|
107
128
|
selectionCount={ctrl.selected.size}
|
|
108
|
-
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] :
|
|
129
|
+
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
|
|
109
130
|
tables={ctrl.tables}
|
|
131
|
+
screens={ctrl.screens}
|
|
132
|
+
lockedNames={ctrl.lockedNames}
|
|
110
133
|
onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
|
|
134
|
+
onScreenChange={ctrl.setScreen}
|
|
111
135
|
onRemove={ctrl.removeSelected}
|
|
112
136
|
/>
|
|
113
137
|
</div>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { LABEL_PRESETS } from '../controls.js'
|
|
2
|
+
import ListView from './ListView.jsx'
|
|
3
|
+
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
4
|
+
|
|
1
5
|
// How each control LOOKS — the one renderer, used both on the builder's
|
|
2
6
|
// canvas (mode 'design': inert, so a press drags it) and on a live screen
|
|
3
7
|
// (mode 'live'). Sharing it is what makes the builder honest: what you place is
|
|
4
|
-
// what the form shows.
|
|
8
|
+
// what the form shows, fonts and colours included.
|
|
5
9
|
//
|
|
6
|
-
// Presentation only. Values, options and
|
|
10
|
+
// Presentation only. Values, options, errors and list data arrive as props.
|
|
7
11
|
|
|
8
12
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
9
13
|
|
|
10
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange,
|
|
14
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list }) {
|
|
11
15
|
const View = VIEWS[node.type]
|
|
12
16
|
if (!View) {
|
|
13
17
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -22,17 +26,18 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
22
26
|
error={design ? null : error}
|
|
23
27
|
options={options || { loading: false, items: [], error: null }}
|
|
24
28
|
onChange={onChange || (() => {})}
|
|
25
|
-
|
|
29
|
+
onBlur={onBlur || (() => {})}
|
|
26
30
|
disabled={design || disabled}
|
|
31
|
+
list={list}
|
|
27
32
|
/>
|
|
28
33
|
)
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function Field({ node, p, error, children, inline }) {
|
|
32
37
|
return (
|
|
33
|
-
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`}>
|
|
38
|
+
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`} style={fieldStyle(p.style)}>
|
|
34
39
|
{!inline && (
|
|
35
|
-
<label className="xeplr-factory-label" htmlFor={ids(node)}>
|
|
40
|
+
<label className="xeplr-factory-label" htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
36
41
|
{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
|
|
37
42
|
</label>
|
|
38
43
|
)}
|
|
@@ -42,19 +47,21 @@ function Field({ node, p, error, children, inline }) {
|
|
|
42
47
|
)
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
function inputProps(node, p, error, disabled) {
|
|
50
|
+
function inputProps(node, p, error, disabled, onBlur) {
|
|
46
51
|
return {
|
|
47
52
|
id: ids(node),
|
|
48
53
|
name: p.name,
|
|
49
54
|
disabled,
|
|
50
|
-
required: undefined, // validation is ours, with our messages — not the browser's bubbles
|
|
51
55
|
'aria-invalid': error ? true : undefined,
|
|
52
56
|
'aria-describedby': error ? `${ids(node)}-error` : undefined,
|
|
53
|
-
|
|
57
|
+
'aria-required': p.required ? true : undefined,
|
|
58
|
+
tabIndex: disabled ? -1 : undefined,
|
|
59
|
+
onBlur,
|
|
60
|
+
style: boxStyle(p.style)
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
function TextView({ node, p, value, error, onChange, disabled }) {
|
|
64
|
+
function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
58
65
|
return (
|
|
59
66
|
<Field node={node} p={p} error={error}>
|
|
60
67
|
<input
|
|
@@ -63,13 +70,13 @@ function TextView({ node, p, value, error, onChange, disabled }) {
|
|
|
63
70
|
placeholder={p.placeholder || ''}
|
|
64
71
|
value={value ?? ''}
|
|
65
72
|
onChange={(e) => onChange(e.target.value)}
|
|
66
|
-
{...inputProps(node, p, error, disabled)}
|
|
73
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
67
74
|
/>
|
|
68
75
|
</Field>
|
|
69
76
|
)
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
function TextareaView({ node, p, value, error, onChange, disabled }) {
|
|
79
|
+
function TextareaView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
73
80
|
return (
|
|
74
81
|
<Field node={node} p={p} error={error}>
|
|
75
82
|
<textarea
|
|
@@ -77,13 +84,13 @@ function TextareaView({ node, p, value, error, onChange, disabled }) {
|
|
|
77
84
|
placeholder={p.placeholder || ''}
|
|
78
85
|
value={value ?? ''}
|
|
79
86
|
onChange={(e) => onChange(e.target.value)}
|
|
80
|
-
{...inputProps(node, p, error, disabled)}
|
|
87
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
81
88
|
/>
|
|
82
89
|
</Field>
|
|
83
90
|
)
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
function NumberView({ node, p, value, error, onChange, disabled }) {
|
|
93
|
+
function NumberView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
87
94
|
const v = p.validation || {}
|
|
88
95
|
return (
|
|
89
96
|
<Field node={node} p={p} error={error}>
|
|
@@ -95,13 +102,13 @@ function NumberView({ node, p, value, error, onChange, disabled }) {
|
|
|
95
102
|
placeholder={p.placeholder || ''}
|
|
96
103
|
value={value ?? ''}
|
|
97
104
|
onChange={(e) => onChange(e.target.value)}
|
|
98
|
-
{...inputProps(node, p, error, disabled)}
|
|
105
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
99
106
|
/>
|
|
100
107
|
</Field>
|
|
101
108
|
)
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
function DateView({ node, p, value, error, onChange, disabled }) {
|
|
111
|
+
function DateView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
105
112
|
const v = p.validation || {}
|
|
106
113
|
return (
|
|
107
114
|
<Field node={node} p={p} error={error}>
|
|
@@ -112,21 +119,23 @@ function DateView({ node, p, value, error, onChange, disabled }) {
|
|
|
112
119
|
max={v.max}
|
|
113
120
|
value={value ?? ''}
|
|
114
121
|
onChange={(e) => onChange(e.target.value)}
|
|
115
|
-
{...inputProps(node, p, error, disabled)}
|
|
122
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
116
123
|
/>
|
|
117
124
|
</Field>
|
|
118
125
|
)
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
function CheckboxView({ node, p, value, error, onChange, disabled }) {
|
|
128
|
+
function CheckboxView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
129
|
+
const text = boxStyle(p.style)
|
|
122
130
|
return (
|
|
123
131
|
<Field node={node} p={p} error={error} inline>
|
|
124
|
-
<label className="xeplr-factory-check" htmlFor={ids(node)}>
|
|
132
|
+
<label className="xeplr-factory-check" htmlFor={ids(node)} style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
125
133
|
<input
|
|
126
134
|
type="checkbox"
|
|
127
135
|
checked={Boolean(value ?? p.default)}
|
|
128
136
|
onChange={(e) => onChange(e.target.checked)}
|
|
129
|
-
{...inputProps(node, p, error, disabled)}
|
|
137
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
138
|
+
style={undefined}
|
|
130
139
|
/>
|
|
131
140
|
<span>{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}</span>
|
|
132
141
|
</label>
|
|
@@ -134,7 +143,7 @@ function CheckboxView({ node, p, value, error, onChange, disabled }) {
|
|
|
134
143
|
)
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
function DropdownView({ node, p, design, value, error, options, onChange, disabled }) {
|
|
146
|
+
function DropdownView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
138
147
|
const data = p.data || {}
|
|
139
148
|
const items = data.source === 'static' ? (data.options || []) : options.items
|
|
140
149
|
return (
|
|
@@ -143,7 +152,7 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
|
|
|
143
152
|
className="xeplr-factory-input xeplr-factory-select"
|
|
144
153
|
value={value === undefined || value === null ? '' : String(value)}
|
|
145
154
|
onChange={(e) => onChange(e.target.value)}
|
|
146
|
-
{...inputProps(node, p, error, disabled || (!design && options.loading))}
|
|
155
|
+
{...inputProps(node, p, error, disabled || (!design && options.loading), onBlur)}
|
|
147
156
|
>
|
|
148
157
|
<option value="">{!design && options.loading ? 'Loading…' : (p.placeholder || 'Select…')}</option>
|
|
149
158
|
{!design && items.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
|
|
@@ -161,25 +170,23 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
|
|
|
161
170
|
|
|
162
171
|
function LabelView({ p }) {
|
|
163
172
|
const variant = p.variant || 'text'
|
|
173
|
+
const preset = LABEL_PRESETS[variant] || LABEL_PRESETS.text
|
|
164
174
|
const Tag = variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function ButtonView({ p, design, onAction, disabled }) {
|
|
169
|
-
const action = p.action || 'submit'
|
|
175
|
+
const s = { fontSize: preset.fontSize, fontWeight: preset.fontWeight, ...(p.style || {}) }
|
|
170
176
|
return (
|
|
171
|
-
<
|
|
172
|
-
className={`xeplr-factory-
|
|
173
|
-
|
|
174
|
-
disabled={disabled}
|
|
175
|
-
tabIndex={design ? -1 : undefined}
|
|
176
|
-
onClick={action === 'reset' && !design ? () => onAction('reset') : undefined}
|
|
177
|
+
<Tag
|
|
178
|
+
className={`xeplr-factory-text xeplr-factory-text--${variant}`}
|
|
179
|
+
style={{ ...fieldStyle(s), ...boxStyle(s) }}
|
|
177
180
|
>
|
|
178
|
-
{p.
|
|
179
|
-
</
|
|
181
|
+
{p.text}
|
|
182
|
+
</Tag>
|
|
180
183
|
)
|
|
181
184
|
}
|
|
182
185
|
|
|
186
|
+
function ListControl({ node, design, list }) {
|
|
187
|
+
return <ListView node={node} design={design} {...(list || {})} />
|
|
188
|
+
}
|
|
189
|
+
|
|
183
190
|
export const VIEWS = {
|
|
184
191
|
text: TextView,
|
|
185
192
|
textarea: TextareaView,
|
|
@@ -188,5 +195,5 @@ export const VIEWS = {
|
|
|
188
195
|
checkbox: CheckboxView,
|
|
189
196
|
dropdown: DropdownView,
|
|
190
197
|
label: LabelView,
|
|
191
|
-
|
|
198
|
+
list: ListControl
|
|
192
199
|
}
|