@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/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`, and the
4
- // form schema reads `valueType`. Adding a control is adding an entry.
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.06 },
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.06 },
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: 'Style', type: 'select', options: [
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
- export const BUTTON_ACTIONS = ['submit', 'reset']
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 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.
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 @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.
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="xeplr-factory-bar-status" aria-live="polite">
48
- {ctrl.saving ? 'Saving…' : ctrl.dirty ? 'Unsaved changes' : ''}
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={`xeplr-factory-problems${ctrl.showErrors ? ' is-loud' : ''}`} title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
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
- <button type="button" className="xeplr-factory-primary" onClick={ctrl.save} disabled={ctrl.saving}>
61
- Save
62
- </button>
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.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>)}
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
- <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
- />
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] : null}
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 errors arrive as props.
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, onAction, disabled }) {
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
- onAction={onAction || (() => {})}
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
- tabIndex: disabled ? -1 : undefined
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
- 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'
175
+ const s = { fontSize: preset.fontSize, fontWeight: preset.fontWeight, ...(p.style || {}) }
170
176
  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
+ <Tag
178
+ className={`xeplr-factory-text xeplr-factory-text--${variant}`}
179
+ style={{ ...fieldStyle(s), ...boxStyle(s) }}
177
180
  >
178
- {p.label}
179
- </button>
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
- button: ButtonView
198
+ list: ListControl
192
199
  }