@xeplr/ui-factory 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +16 -5
- package/README.md +61 -9
- package/package.json +1 -1
- package/src/controls.js +191 -0
- package/src/designs/BuilderSample.jsx +38 -4
- package/src/designs/ControlView.jsx +202 -2
- package/src/designs/PropertyPanel.jsx +71 -1
- package/src/designs/ScreenSample.jsx +34 -3
- package/src/designs/factory.css +65 -0
- package/src/document.js +67 -3
- package/src/generate.js +83 -19
- package/src/model.js +4 -3
- package/src/remote.js +32 -2
- package/src/tableSchema.js +16 -2
- package/src/useFactoryBuilder.js +40 -3
- package/src/useFactoryScreen.js +61 -5
- package/src/validateDocument.js +89 -6
- package/src/values.js +89 -10
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import { LABEL_PRESETS } from '../controls.js'
|
|
3
|
+
import { stepsOf } from '../document.js'
|
|
2
4
|
import ListView from './ListView.jsx'
|
|
3
5
|
import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
4
6
|
|
|
@@ -11,7 +13,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
|
|
|
11
13
|
|
|
12
14
|
const ids = (node) => `xeplr-factory-${node.id}`
|
|
13
15
|
|
|
14
|
-
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list }) {
|
|
16
|
+
export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper }) {
|
|
15
17
|
const View = VIEWS[node.type]
|
|
16
18
|
if (!View) {
|
|
17
19
|
return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
|
|
@@ -29,6 +31,8 @@ export default function ControlView({ node, mode = 'live', value, error, options
|
|
|
29
31
|
onBlur={onBlur || (() => {})}
|
|
30
32
|
disabled={design || disabled}
|
|
31
33
|
list={list}
|
|
34
|
+
upload={upload}
|
|
35
|
+
stepper={stepper}
|
|
32
36
|
/>
|
|
33
37
|
)
|
|
34
38
|
}
|
|
@@ -37,7 +41,7 @@ function Field({ node, p, error, children, inline }) {
|
|
|
37
41
|
return (
|
|
38
42
|
<div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`} style={fieldStyle(p.style)}>
|
|
39
43
|
{!inline && (
|
|
40
|
-
<label className="xeplr-factory-label" htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
44
|
+
<label className="xeplr-factory-label" id={`${ids(node)}-label`} htmlFor={ids(node)} style={labelStyle(p.style)}>
|
|
41
45
|
{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
|
|
42
46
|
</label>
|
|
43
47
|
)}
|
|
@@ -168,6 +172,197 @@ function DropdownView({ node, p, design, value, error, options, onChange, onBlur
|
|
|
168
172
|
)
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/** The options a group control shows: typed in, or loaded from a table. */
|
|
176
|
+
function groupItems(p, design, options) {
|
|
177
|
+
const data = p.data || {}
|
|
178
|
+
return data.source === 'static' ? (data.options || []) : (design ? [] : options.items)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What the designer shows in place of options it cannot load on the canvas. */
|
|
182
|
+
function OptionsHint({ data }) {
|
|
183
|
+
const n = (data.options || []).length
|
|
184
|
+
return (
|
|
185
|
+
<div className="xeplr-factory-hint">
|
|
186
|
+
{data.source === 'table'
|
|
187
|
+
? (data.table ? `from table ${data.table}` : 'no table chosen')
|
|
188
|
+
: `${n} option${n === 1 ? '' : 's'}`}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RadioView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
194
|
+
const items = groupItems(p, design, options)
|
|
195
|
+
const text = boxStyle(p.style)
|
|
196
|
+
return (
|
|
197
|
+
<Field node={node} p={p} error={error || (!design && options.error) || null}>
|
|
198
|
+
<div className={`xeplr-factory-group xeplr-factory-group--${p.layout === 'horizontal' ? 'horizontal' : 'vertical'}`} role="radiogroup" aria-labelledby={`${ids(node)}-label`}>
|
|
199
|
+
{items.map((o) => (
|
|
200
|
+
<label key={String(o.id)} className="xeplr-factory-option" style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
201
|
+
<input
|
|
202
|
+
type="radio"
|
|
203
|
+
name={p.name}
|
|
204
|
+
value={String(o.id)}
|
|
205
|
+
checked={value !== undefined && value !== null && String(value) === String(o.id)}
|
|
206
|
+
disabled={disabled || (!design && options.loading)}
|
|
207
|
+
tabIndex={disabled ? -1 : undefined}
|
|
208
|
+
onChange={() => onChange(o.id)}
|
|
209
|
+
onBlur={onBlur}
|
|
210
|
+
/>
|
|
211
|
+
<span>{o.name}</span>
|
|
212
|
+
</label>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
{design && <OptionsHint data={p.data || {}} />}
|
|
216
|
+
{!design && options.loading && <div className="xeplr-factory-hint">Loading…</div>}
|
|
217
|
+
</Field>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function MultiselectView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
|
|
222
|
+
const items = groupItems(p, design, options)
|
|
223
|
+
const chosen = Array.isArray(value) ? value : []
|
|
224
|
+
const text = boxStyle(p.style)
|
|
225
|
+
const toggle = (id, on) => {
|
|
226
|
+
const next = chosen.filter((v) => String(v) !== String(id))
|
|
227
|
+
onChange(on ? [...next, id] : next)
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
<Field node={node} p={p} error={error || (!design && options.error) || null}>
|
|
231
|
+
<div className={`xeplr-factory-group xeplr-factory-group--${p.layout === 'horizontal' ? 'horizontal' : 'vertical'}`} role="group" aria-labelledby={`${ids(node)}-label`}>
|
|
232
|
+
{items.map((o) => (
|
|
233
|
+
<label key={String(o.id)} className="xeplr-factory-option" style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
|
|
234
|
+
<input
|
|
235
|
+
type="checkbox"
|
|
236
|
+
name={p.name}
|
|
237
|
+
value={String(o.id)}
|
|
238
|
+
checked={chosen.some((v) => String(v) === String(o.id))}
|
|
239
|
+
disabled={disabled || (!design && options.loading)}
|
|
240
|
+
tabIndex={disabled ? -1 : undefined}
|
|
241
|
+
onChange={(e) => toggle(o.id, e.target.checked)}
|
|
242
|
+
onBlur={onBlur}
|
|
243
|
+
/>
|
|
244
|
+
<span>{o.name}</span>
|
|
245
|
+
</label>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
{design && <OptionsHint data={p.data || {}} />}
|
|
249
|
+
{!design && options.loading && <div className="xeplr-factory-hint">Loading…</div>}
|
|
250
|
+
</Field>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function DatetimeView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
255
|
+
const v = p.validation || {}
|
|
256
|
+
return (
|
|
257
|
+
<Field node={node} p={p} error={error}>
|
|
258
|
+
<input
|
|
259
|
+
className="xeplr-factory-input"
|
|
260
|
+
type="datetime-local"
|
|
261
|
+
min={v.min}
|
|
262
|
+
max={v.max}
|
|
263
|
+
value={value ?? ''}
|
|
264
|
+
onChange={(e) => onChange(e.target.value)}
|
|
265
|
+
{...inputProps(node, p, error, disabled, onBlur)}
|
|
266
|
+
/>
|
|
267
|
+
</Field>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A file field holds the stored file's PATH, not the file. Picking one uploads
|
|
273
|
+
* it straight away (`upload`, supplied by the screen) and what comes back is
|
|
274
|
+
* the value that is saved with the record.
|
|
275
|
+
*/
|
|
276
|
+
function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload }) {
|
|
277
|
+
const [busy, setBusy] = useState(false)
|
|
278
|
+
const [failed, setFailed] = useState(null)
|
|
279
|
+
const accept = p.accept || ''
|
|
280
|
+
const choose = async (file) => {
|
|
281
|
+
if (!file || !upload) return
|
|
282
|
+
setFailed(null)
|
|
283
|
+
setBusy(true)
|
|
284
|
+
try {
|
|
285
|
+
const out = await upload(file, node)
|
|
286
|
+
onChange(out && out.path ? out.path : undefined)
|
|
287
|
+
} catch (e) {
|
|
288
|
+
setFailed(e && e.message ? e.message : 'The file could not be uploaded')
|
|
289
|
+
} finally {
|
|
290
|
+
setBusy(false)
|
|
291
|
+
onBlur()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return (
|
|
295
|
+
<Field node={node} p={p} error={error || failed}>
|
|
296
|
+
{value
|
|
297
|
+
? (
|
|
298
|
+
<div className="xeplr-factory-file">
|
|
299
|
+
<span className="xeplr-factory-file-name" title={String(value)}>{fileName(value)}</span>
|
|
300
|
+
<button type="button" className="xeplr-factory-file-clear" disabled={disabled} onClick={() => { onChange(undefined); onBlur() }}>Remove</button>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
: (
|
|
304
|
+
<input
|
|
305
|
+
className="xeplr-factory-input xeplr-factory-file-input"
|
|
306
|
+
type="file"
|
|
307
|
+
accept={accept}
|
|
308
|
+
disabled={disabled || busy}
|
|
309
|
+
onChange={(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; choose(f) }}
|
|
310
|
+
{...inputProps(node, p, error, disabled || busy, onBlur)}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
{busy && <div className="xeplr-factory-hint">Uploading…</div>}
|
|
314
|
+
{!busy && !value && (design || accept) && (
|
|
315
|
+
<div className="xeplr-factory-hint">{accept || 'any file'}{p.maxSize ? ` · up to ${p.maxSize} MB` : ''}</div>
|
|
316
|
+
)}
|
|
317
|
+
</Field>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The name at the end of a stored path, for showing what is attached. */
|
|
322
|
+
export function fileName(path) {
|
|
323
|
+
const s = String(path)
|
|
324
|
+
const cut = s.slice(s.lastIndexOf('/') + 1)
|
|
325
|
+
// Stored names start with the id that keeps them apart: "a1b2c3__report.pdf".
|
|
326
|
+
const sep = cut.indexOf('__')
|
|
327
|
+
return sep === -1 ? cut : cut.slice(sep + 2)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The journey across the top: a circle per step, the ones behind it done. It is
|
|
332
|
+
* the same bar on the canvas and on a live screen — clicking a step opens it,
|
|
333
|
+
* which in the builder is how you get at that step's controls.
|
|
334
|
+
*/
|
|
335
|
+
function StepperView({ node, p, design, stepper }) {
|
|
336
|
+
const steps = stepsOf(node)
|
|
337
|
+
const active = stepper && Number.isInteger(stepper.active) ? stepper.active : 0
|
|
338
|
+
const go = stepper && stepper.onStep
|
|
339
|
+
const text = boxStyle(p.style)
|
|
340
|
+
return (
|
|
341
|
+
<div className="xeplr-factory-stepper" style={{ ...fieldStyle(p.style), ...text }}>
|
|
342
|
+
<ol className="xeplr-factory-steps">
|
|
343
|
+
{steps.map((s, i) => {
|
|
344
|
+
const state = i === active ? 'is-active' : i < active ? 'is-done' : ''
|
|
345
|
+
return (
|
|
346
|
+
<li key={s.key} className={`xeplr-factory-step ${state}`} aria-current={i === active ? 'step' : undefined}>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className="xeplr-factory-step-button"
|
|
350
|
+
// On the canvas a press must still be able to drag the control,
|
|
351
|
+
// so the step is taken on click, never on mousedown.
|
|
352
|
+
onClick={(e) => { if (go) { e.stopPropagation(); go(i) } }}
|
|
353
|
+
tabIndex={design ? -1 : undefined}
|
|
354
|
+
>
|
|
355
|
+
<span className="xeplr-factory-step-mark" aria-hidden="true">{i < active ? '✓' : (p.showNumbers === false ? '' : i + 1)}</span>
|
|
356
|
+
<span className="xeplr-factory-step-label">{s.label}</span>
|
|
357
|
+
</button>
|
|
358
|
+
</li>
|
|
359
|
+
)
|
|
360
|
+
})}
|
|
361
|
+
</ol>
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
171
366
|
function LabelView({ p }) {
|
|
172
367
|
const variant = p.variant || 'text'
|
|
173
368
|
const preset = LABEL_PRESETS[variant] || LABEL_PRESETS.text
|
|
@@ -194,6 +389,11 @@ export const VIEWS = {
|
|
|
194
389
|
date: DateView,
|
|
195
390
|
checkbox: CheckboxView,
|
|
196
391
|
dropdown: DropdownView,
|
|
392
|
+
radio: RadioView,
|
|
393
|
+
multiselect: MultiselectView,
|
|
394
|
+
datetime: DatetimeView,
|
|
395
|
+
file: FileView,
|
|
396
|
+
stepper: StepperView,
|
|
197
397
|
label: LabelView,
|
|
198
398
|
list: ListControl
|
|
199
399
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAtPath } from '../propertyPath.js'
|
|
2
2
|
import { slugify, inputNodes } from '../document.js'
|
|
3
|
-
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS } from '../controls.js'
|
|
3
|
+
import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
|
|
4
4
|
|
|
5
5
|
// The selected control's properties, GENERATED from its entry in controls.js:
|
|
6
6
|
// each field there names a path and an editor type, and this file owns only
|
|
@@ -152,6 +152,39 @@ function DateEditor({ id, value, onChange }) {
|
|
|
152
152
|
return <input id={id} className="xeplr-factory-prop-input" type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function DatetimeEditor({ id, value, onChange }) {
|
|
156
|
+
return <input id={id} className="xeplr-factory-prop-input" type="datetime-local" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which files a file field takes: one of the everyday sets, or a list typed
|
|
161
|
+
* out. The list is what is saved either way — the sets are a way in, not a
|
|
162
|
+
* second setting to keep in step.
|
|
163
|
+
*/
|
|
164
|
+
function AcceptEditor({ id, value, onChange }) {
|
|
165
|
+
const kind = Object.keys(FILE_KINDS).find((k) => FILE_KINDS[k].accept === value)
|
|
166
|
+
return (
|
|
167
|
+
<div className="xeplr-factory-prop-stack">
|
|
168
|
+
<select
|
|
169
|
+
id={id}
|
|
170
|
+
className="xeplr-factory-prop-input"
|
|
171
|
+
value={kind || '__custom'}
|
|
172
|
+
onChange={(e) => { const k = e.target.value; if (k !== '__custom') onChange(FILE_KINDS[k].accept) }}
|
|
173
|
+
>
|
|
174
|
+
{Object.entries(FILE_KINDS).map(([k, f]) => <option key={k} value={k}>{f.label}</option>)}
|
|
175
|
+
<option value="__custom">Just these…</option>
|
|
176
|
+
</select>
|
|
177
|
+
<input
|
|
178
|
+
className="xeplr-factory-prop-input"
|
|
179
|
+
type="text"
|
|
180
|
+
value={value ?? ''}
|
|
181
|
+
placeholder=".pdf,.docx"
|
|
182
|
+
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
155
188
|
/** A font family: the common ones, or anything typed. */
|
|
156
189
|
function FontEditor({ id, value, onChange }) {
|
|
157
190
|
const known = FONT_FAMILIES.some((f) => f.value === value)
|
|
@@ -384,11 +417,48 @@ function StaticOptions({ data, onChange, errors }) {
|
|
|
384
417
|
)
|
|
385
418
|
}
|
|
386
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The steps, as one line: "Connect, Transform, Review". A step KEEPS ITS KEY
|
|
422
|
+
* while it is renamed — the controls on it point at the key — so editing the
|
|
423
|
+
* line matches by position, and only a step that is added or removed changes
|
|
424
|
+
* which controls are shown.
|
|
425
|
+
*/
|
|
426
|
+
function StepsEditor({ id, value, onChange }) {
|
|
427
|
+
const steps = Array.isArray(value) ? value : []
|
|
428
|
+
return (
|
|
429
|
+
<input
|
|
430
|
+
id={id}
|
|
431
|
+
className="xeplr-factory-prop-input"
|
|
432
|
+
type="text"
|
|
433
|
+
value={steps.map((s) => s.label).join(', ')}
|
|
434
|
+
placeholder="Connect, Transform, Review"
|
|
435
|
+
onChange={(e) => {
|
|
436
|
+
const labels = e.target.value.split(',').map((t) => t.trim()).filter(Boolean)
|
|
437
|
+
onChange(labels.map((label, i) => ({
|
|
438
|
+
key: steps[i] ? steps[i].key : uniqueStepKey(steps, slugify(label, '_') || `step_${i + 1}`),
|
|
439
|
+
label
|
|
440
|
+
})))
|
|
441
|
+
}}
|
|
442
|
+
/>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function uniqueStepKey(steps, base) {
|
|
447
|
+
const taken = new Set(steps.map((s) => s.key))
|
|
448
|
+
if (!taken.has(base)) return base
|
|
449
|
+
let n = 2
|
|
450
|
+
while (taken.has(`${base}_${n}`)) n++
|
|
451
|
+
return `${base}_${n}`
|
|
452
|
+
}
|
|
453
|
+
|
|
387
454
|
export const EDITORS = {
|
|
388
455
|
text: TextEditor,
|
|
389
456
|
textarea: TextareaEditor,
|
|
390
457
|
number: NumberEditor,
|
|
391
458
|
date: DateEditor,
|
|
459
|
+
datetime: DatetimeEditor,
|
|
460
|
+
accept: AcceptEditor,
|
|
461
|
+
steps: StepsEditor,
|
|
392
462
|
toggle: ToggleEditor,
|
|
393
463
|
toggleValue: ToggleValueEditor,
|
|
394
464
|
select: SelectEditor,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ControlView from './ControlView.jsx'
|
|
2
2
|
import { contentBottom, inputNodes, MARGIN } from '../document.js'
|
|
3
3
|
import { screenStyle } from './styles.js'
|
|
4
|
+
import { CHOICE_TYPES } from '../controls.js'
|
|
4
5
|
|
|
5
6
|
// A saved screen, drawn. Presentation only — everything comes from
|
|
6
7
|
// useFactoryScreen.
|
|
@@ -21,6 +22,28 @@ const STATUS = {
|
|
|
21
22
|
invalid: 'Fix the highlighted fields to save'
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Back and Next, under the page. Next holds at a step whose own fields are not
|
|
27
|
+
* filled in — and shows why, rather than moving on and leaving a problem
|
|
28
|
+
* behind on a step nobody is looking at.
|
|
29
|
+
*/
|
|
30
|
+
function StepNav({ ctrl, node }) {
|
|
31
|
+
const at = ctrl.steps.active(node.id)
|
|
32
|
+
const last = ctrl.steps.count(node.id) - 1
|
|
33
|
+
const unfinished = ctrl.steps.fieldsOn(node.id, at).filter((n) => ctrl.allErrors[n.props.name])
|
|
34
|
+
const go = (to) => {
|
|
35
|
+
if (to > at && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
|
|
36
|
+
ctrl.steps.go(node.id, Math.min(Math.max(to, 0), last))
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<div className="xeplr-factory-stepnav">
|
|
40
|
+
<button type="button" className="xeplr-factory-secondary" onClick={() => go(at - 1)} disabled={at === 0}>Back</button>
|
|
41
|
+
<span className="xeplr-factory-stepnav-where">Step {at + 1} of {last + 1}</span>
|
|
42
|
+
<button type="button" className="xeplr-factory-primary" onClick={() => go(at + 1)} disabled={at === last}>Next</button>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
25
48
|
const { document: doc } = ctrl
|
|
26
49
|
|
|
@@ -36,7 +59,10 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
const aspect = doc.aspect
|
|
39
|
-
|
|
62
|
+
// Only what the open steps show — the page is as tall as the step being
|
|
63
|
+
// filled in, not as tall as every step stacked up.
|
|
64
|
+
const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
|
|
65
|
+
const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
|
|
40
66
|
const fieldNodes = inputNodes(doc)
|
|
41
67
|
const statusText = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
42
68
|
// A screen with no fields of its own (a list screen) has nothing to save.
|
|
@@ -61,7 +87,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
61
87
|
noValidate
|
|
62
88
|
aria-label={doc.name}
|
|
63
89
|
>
|
|
64
|
-
{[...
|
|
90
|
+
{[...shown].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
|
|
65
91
|
<div
|
|
66
92
|
key={node.id}
|
|
67
93
|
className={`xeplr-factory-node xeplr-factory-node--${node.type}`}
|
|
@@ -77,7 +103,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
77
103
|
mode="live"
|
|
78
104
|
value={node.props?.name ? ctrl.values[node.props.name] : undefined}
|
|
79
105
|
error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
|
|
80
|
-
options={node.type
|
|
106
|
+
options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
|
|
107
|
+
upload={node.type === 'file' ? ctrl.upload : undefined}
|
|
108
|
+
stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i) } : undefined}
|
|
81
109
|
onChange={(raw) => ctrl.setValue(node, raw)}
|
|
82
110
|
onBlur={() => ctrl.touch(node)}
|
|
83
111
|
list={node.type === 'list' ? {
|
|
@@ -100,6 +128,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
|
|
|
100
128
|
</div>
|
|
101
129
|
))}
|
|
102
130
|
</form>
|
|
131
|
+
{ctrl.steps.nodes.map((node) => (
|
|
132
|
+
<StepNav key={node.id} ctrl={ctrl} node={node} />
|
|
133
|
+
))}
|
|
103
134
|
{ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
|
|
104
135
|
</div>
|
|
105
136
|
)
|
package/src/designs/factory.css
CHANGED
|
@@ -71,6 +71,64 @@
|
|
|
71
71
|
.xeplr-factory-check { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; font-weight: 500; }
|
|
72
72
|
.xeplr-factory-check input { width: calc(18 * var(--xf-u)); height: calc(18 * var(--xf-u)); min-width: 12px; min-height: 12px; margin: 0; accent-color: var(--xf-accent); }
|
|
73
73
|
|
|
74
|
+
/* ── option groups (radio, multi-select) ───────────────────────────────────
|
|
75
|
+
The group scrolls rather than spilling over the control below it, so a long
|
|
76
|
+
list of options cannot cover the next field. */
|
|
77
|
+
.xeplr-factory-group { display: flex; gap: calc(6 * var(--xf-u)) calc(16 * var(--xf-u)); overflow: auto; min-height: 0; }
|
|
78
|
+
.xeplr-factory-group--vertical { flex-direction: column; }
|
|
79
|
+
.xeplr-factory-group--horizontal { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
|
80
|
+
.xeplr-factory-option { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
|
|
81
|
+
.xeplr-factory-option input { width: calc(16 * var(--xf-u)); height: calc(16 * var(--xf-u)); min-width: 11px; min-height: 11px; margin: 0; accent-color: var(--xf-accent); }
|
|
82
|
+
|
|
83
|
+
/* ── file ─────────────────────────────────────────────────────────────────── */
|
|
84
|
+
.xeplr-factory-file { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); min-width: 0; }
|
|
85
|
+
.xeplr-factory-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
|
|
86
|
+
.xeplr-factory-file-clear {
|
|
87
|
+
flex: none; border: 1px solid var(--xf-border); background: var(--xf-surface); color: var(--xf-muted);
|
|
88
|
+
border-radius: calc(6 * var(--xf-u)); padding: calc(3 * var(--xf-u)) calc(8 * var(--xf-u)); cursor: pointer; font: inherit;
|
|
89
|
+
}
|
|
90
|
+
.xeplr-factory-file-clear:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
|
|
91
|
+
.xeplr-factory-file-input { padding: calc(5 * var(--xf-u)); }
|
|
92
|
+
|
|
93
|
+
/* ── stepper ──────────────────────────────────────────────────────────────
|
|
94
|
+
The journey across the top: a circle per step, joined by a line. The line is
|
|
95
|
+
drawn behind the circles, from the middle of one to the middle of the next. */
|
|
96
|
+
.xeplr-factory-stepper { display: flex; align-items: center; width: 100%; height: 100%; overflow: hidden; }
|
|
97
|
+
.xeplr-factory-steps { display: flex; align-items: flex-start; width: 100%; margin: 0; padding: 0; list-style: none; }
|
|
98
|
+
.xeplr-factory-step { flex: 1; position: relative; min-width: 0; text-align: center; }
|
|
99
|
+
.xeplr-factory-step + .xeplr-factory-step::before {
|
|
100
|
+
content: ''; position: absolute; top: calc(13 * var(--xf-u)); right: 50%; left: -50%; height: 2px;
|
|
101
|
+
background: var(--xf-border);
|
|
102
|
+
}
|
|
103
|
+
.xeplr-factory-step.is-active::before, .xeplr-factory-step.is-done::before { background: var(--xf-accent); }
|
|
104
|
+
.xeplr-factory-step-button {
|
|
105
|
+
position: relative; display: flex; flex-direction: column; align-items: center; gap: calc(4 * var(--xf-u));
|
|
106
|
+
width: 100%; padding: 0; border: none; background: none; color: var(--xf-muted); font: inherit; cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
.xeplr-factory-step-mark {
|
|
109
|
+
display: flex; align-items: center; justify-content: center;
|
|
110
|
+
width: calc(26 * var(--xf-u)); height: calc(26 * var(--xf-u)); min-width: 18px; min-height: 18px;
|
|
111
|
+
border-radius: 50%; background: var(--xf-border); color: var(--xf-ink); font-weight: 600;
|
|
112
|
+
}
|
|
113
|
+
.xeplr-factory-step-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
114
|
+
.xeplr-factory-step.is-active .xeplr-factory-step-mark,
|
|
115
|
+
.xeplr-factory-step.is-done .xeplr-factory-step-mark { background: var(--xf-accent); color: #fff; }
|
|
116
|
+
.xeplr-factory-step.is-active .xeplr-factory-step-button { color: var(--xf-accent); font-weight: 600; }
|
|
117
|
+
|
|
118
|
+
/* Back / Next, under the page. */
|
|
119
|
+
.xeplr-factory-stepnav { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
|
120
|
+
.xeplr-factory-stepnav-where { flex: 1; text-align: center; color: var(--xf-muted); font-size: 12px; }
|
|
121
|
+
|
|
122
|
+
/* Which step the builder is on. */
|
|
123
|
+
.xeplr-factory-building { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 auto 8px; padding: 6px 0; }
|
|
124
|
+
.xeplr-factory-building-what { color: var(--xf-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
125
|
+
.xeplr-factory-building-step {
|
|
126
|
+
border: 1px solid var(--xf-border); background: var(--xf-surface); color: var(--xf-muted);
|
|
127
|
+
border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer;
|
|
128
|
+
}
|
|
129
|
+
.xeplr-factory-building-step.is-active { border-color: var(--xf-accent); color: var(--xf-accent); font-weight: 600; }
|
|
130
|
+
.xeplr-factory-building-every { display: flex; align-items: center; gap: 6px; margin-left: auto; color: var(--xf-muted); font-size: 12px; }
|
|
131
|
+
|
|
74
132
|
/* A label's size and weight are inline — its preset, overridden by its style. */
|
|
75
133
|
.xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
|
|
76
134
|
.xeplr-factory-text--text { color: var(--xf-muted); }
|
|
@@ -144,6 +202,11 @@
|
|
|
144
202
|
.xeplr-factory-palette-item:hover { border-color: var(--xf-accent); }
|
|
145
203
|
.xeplr-factory-palette-icon { width: 14px; height: 10px; border: 1.5px solid var(--xf-muted); border-radius: 2px; flex: 0 0 auto; }
|
|
146
204
|
.xeplr-factory-palette-icon--checkbox { width: 10px; }
|
|
205
|
+
.xeplr-factory-palette-icon--radio { border-radius: 50%; width: 11px; height: 11px; }
|
|
206
|
+
.xeplr-factory-palette-icon--multiselect { height: 11px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 2px, transparent 2px 5px); border: none; }
|
|
207
|
+
.xeplr-factory-palette-icon--datetime { border-top-width: 3px; }
|
|
208
|
+
.xeplr-factory-palette-icon--file { border-radius: 2px; border-top: 3px solid var(--xf-muted); height: 12px; width: 10px; }
|
|
209
|
+
.xeplr-factory-palette-icon--stepper { height: 8px; border-radius: 999px; background: linear-gradient(to right, var(--xf-muted) 0 30%, transparent 30%); border: none; }
|
|
147
210
|
.xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
|
|
148
211
|
.xeplr-factory-palette-icon--list { height: 12px; background: repeating-linear-gradient(to bottom, var(--xf-muted) 0 1.5px, transparent 1.5px 4px); border: none; }
|
|
149
212
|
.xeplr-factory-palette-icon--textarea { height: 14px; }
|
|
@@ -184,6 +247,8 @@
|
|
|
184
247
|
border: 1px solid var(--xf-border); border-radius: 5px; background: #fff; color: var(--xf-ink, #1a1a19);
|
|
185
248
|
}
|
|
186
249
|
.xeplr-factory-prop.has-error .xeplr-factory-prop-input { border-color: var(--xf-danger); }
|
|
250
|
+
/* An editor made of two inputs — a set to pick from, and the list it fills in. */
|
|
251
|
+
.xeplr-factory-prop-stack { display: flex; flex-direction: column; gap: 5px; }
|
|
187
252
|
.xeplr-factory-prop-help { margin-top: 3px; color: var(--xf-muted); font-size: 11px; line-height: 1.4; }
|
|
188
253
|
.xeplr-factory-prop-error { margin-top: 3px; color: var(--xf-danger); font-size: 11px; }
|
|
189
254
|
.xeplr-factory-panel-errors { margin-top: 8px; padding: 8px; border-radius: 6px; color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); font-size: 11px; }
|
package/src/document.js
CHANGED
|
@@ -68,7 +68,7 @@ export function setScreenProperty(doc, path, value) {
|
|
|
68
68
|
* control's top-left) or, without one, below everything already on the screen.
|
|
69
69
|
* Returns { document, node }.
|
|
70
70
|
*/
|
|
71
|
-
export function addControl(doc, type, { at, props, size } = {}, controls = CONTROLS) {
|
|
71
|
+
export function addControl(doc, type, { at, props, size, step } = {}, controls = CONTROLS) {
|
|
72
72
|
const def = controls[type]
|
|
73
73
|
if (!def) throw new Error(`Unknown control type "${type}"`)
|
|
74
74
|
const dims = { ...def.defaultSize, ...size }
|
|
@@ -88,9 +88,73 @@ export function addControl(doc, type, { at, props, size } = {}, controls = CONTR
|
|
|
88
88
|
z: nextZ(doc),
|
|
89
89
|
props: merged
|
|
90
90
|
}
|
|
91
|
+
// Dropped while a step is open: the control belongs to that step. A stepper
|
|
92
|
+
// never belongs to itself.
|
|
93
|
+
if (step && type !== 'stepper') node.step = { of: step.of, index: step.index }
|
|
91
94
|
return { document: { ...doc, nodes: [...doc.nodes, node] }, node }
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// ── steps ────────────────────────────────────────────────────────────────
|
|
98
|
+
// A stepper shows one step at a time, and every other control says which step
|
|
99
|
+
// it belongs to. The document stays FLAT — nothing is nested — so a control is
|
|
100
|
+
// still moved, styled and checked exactly as it was; `step` only decides when
|
|
101
|
+
// it is on screen.
|
|
102
|
+
|
|
103
|
+
/** Every stepper on the screen, in reading order. */
|
|
104
|
+
export function steppers(doc, controls = CONTROLS) {
|
|
105
|
+
return (doc.nodes || []).filter((n) => n.type === 'stepper' && controls[n.type])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A stepper's steps, always as [{ key, label }]. */
|
|
109
|
+
export function stepsOf(node) {
|
|
110
|
+
return (node && node.props && Array.isArray(node.props.steps) ? node.props.steps : []).map((s, i) => ({
|
|
111
|
+
key: (s && s.key) || `step_${i + 1}`,
|
|
112
|
+
label: (s && s.label) || `Step ${i + 1}`
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Which step a node is on, or null for "every step". */
|
|
117
|
+
export function stepOf(node) {
|
|
118
|
+
const step = node && node.step
|
|
119
|
+
return step && typeof step.of === 'string' && Number.isInteger(step.index) ? step : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Puts a node on a step — or on none, with `null`, which shows it whatever
|
|
124
|
+
* step is open.
|
|
125
|
+
*/
|
|
126
|
+
export function setNodeStep(doc, id, step) {
|
|
127
|
+
return mapNode(doc, id, (n) => {
|
|
128
|
+
const next = { ...n }
|
|
129
|
+
if (step === null || step === undefined) delete next.step
|
|
130
|
+
else next.step = { of: step.of, index: step.index }
|
|
131
|
+
return next
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The nodes to show, given which step each stepper is open at.
|
|
137
|
+
* @param active { [stepper node id]: index } — missing means its first step
|
|
138
|
+
*/
|
|
139
|
+
export function nodesForSteps(doc, active = {}, controls = CONTROLS) {
|
|
140
|
+
const open = (id) => (Number.isInteger(active[id]) ? active[id] : 0)
|
|
141
|
+
const byId = new Map((doc.nodes || []).map((n) => [n.id, n]))
|
|
142
|
+
const shown = (node, seen) => {
|
|
143
|
+
const step = stepOf(node)
|
|
144
|
+
if (!step) return true
|
|
145
|
+
const owner = byId.get(step.of)
|
|
146
|
+
// A step of a stepper that is gone shows nothing: the control is orphaned,
|
|
147
|
+
// and the checker says so rather than the screen quietly losing it.
|
|
148
|
+
if (!owner || owner.type !== 'stepper') return false
|
|
149
|
+
if (step.index !== open(owner.id)) return false
|
|
150
|
+
// A stepper inside another stepper's step is only open when that one is.
|
|
151
|
+
if (seen.has(owner.id)) return false
|
|
152
|
+
seen.add(owner.id)
|
|
153
|
+
return shown(owner, seen)
|
|
154
|
+
}
|
|
155
|
+
return (doc.nodes || []).filter((n) => shown(n, new Set()))
|
|
156
|
+
}
|
|
157
|
+
|
|
94
158
|
/** Merges a geometry patch — what the canvas reports on drop. */
|
|
95
159
|
export function moveNode(doc, id, patch) {
|
|
96
160
|
const geometry = {}
|
|
@@ -160,8 +224,8 @@ export function readingOrder(nodes) {
|
|
|
160
224
|
}
|
|
161
225
|
|
|
162
226
|
/** Where the lowest control ends, as a page fraction. */
|
|
163
|
-
export function contentBottom(doc) {
|
|
164
|
-
return doc.nodes.reduce((max, n) => Math.max(max, (n.y || 0) + (n.h || 0)), 0)
|
|
227
|
+
export function contentBottom(doc, nodes) {
|
|
228
|
+
return (nodes || doc.nodes).reduce((max, n) => Math.max(max, (n.y || 0) + (n.h || 0)), 0)
|
|
165
229
|
}
|
|
166
230
|
|
|
167
231
|
// ── naming ───────────────────────────────────────────────────────────────
|