@xeplr/ui-factory 1.0.1 → 1.0.3

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,54 @@ 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 && ctrl.publishResult.confirm && (
82
+ <div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm removing columns">
83
+ <div className="xeplr-factory-confirm-text">
84
+ <strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
85
+ <ul>
86
+ {ctrl.publishResult.confirm.map((c) => (
87
+ <li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
88
+ ))}
89
+ </ul>
90
+ This cannot be undone. To keep the data, put the field back before publishing.
91
+ </div>
92
+ <div className="xeplr-factory-confirm-actions">
93
+ <button type="button" className="xeplr-factory-danger" disabled={ctrl.publishing}
94
+ onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column))}>
95
+ {ctrl.publishing ? 'Publishing…' : 'Remove and publish'}
96
+ </button>
97
+ <button type="button" className="xeplr-factory-secondary" onClick={ctrl.clearPublishResult}>Cancel</button>
98
+ </div>
99
+ </div>
100
+ )}
101
+ {ctrl.publishResult && !ctrl.publishResult.confirm && (
102
+ <div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
103
+ <span>{ctrl.publishResult.message}</span>
104
+ {ctrl.publishResult.detail && <pre className="xeplr-factory-publish-detail">{ctrl.publishResult.detail}</pre>}
105
+ <button type="button" className="xeplr-factory-icon-button" aria-label="Dismiss" onClick={ctrl.clearPublishResult}>×</button>
69
106
  </div>
70
107
  )}
71
108
 
@@ -80,34 +117,41 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
80
117
  onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
81
118
  onDrop={onDrop}
82
119
  >
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
- />
120
+ <div className="xeplr-factory-frame" style={{ maxWidth: doc.width }}>
121
+ <XeplrCanvas
122
+ className="xeplr-factory-canvas"
123
+ style={screenStyle(doc)}
124
+ items={doc.nodes}
125
+ units="fraction"
126
+ pageAspect={doc.aspect}
127
+ features={{ resize: true, marquee: true }}
128
+ selection={ctrl.selected}
129
+ onSelectionChange={ctrl.setSelected}
130
+ onItemChange={ctrl.moveControl}
131
+ minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
132
+ renderItem={(node, { selected }) => (
133
+ <div className={`xeplr-factory-design-node${ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
134
+ <ControlView node={node} mode="design" list={node.type === 'list' ? { doc } : undefined} />
135
+ </div>
136
+ )}
137
+ underlay={doc.nodes.length === 0 && (
138
+ <div className="xeplr-factory-empty">Drag controls here from the left</div>
139
+ )}
140
+ />
141
+ </div>
102
142
  </div>
103
143
 
104
144
  <PropertyPanel
145
+ doc={doc}
105
146
  node={ctrl.selectedNode}
106
147
  control={ctrl.selectedControl}
107
148
  selectionCount={ctrl.selected.size}
108
- errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : null}
149
+ errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
109
150
  tables={ctrl.tables}
151
+ screens={ctrl.screens}
152
+ lockedNames={ctrl.lockedNames}
110
153
  onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
154
+ onScreenChange={ctrl.setScreen}
111
155
  onRemove={ctrl.removeSelected}
112
156
  />
113
157
  </div>