@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.
@@ -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
- const pageHeight = (contentBottom(doc) + MARGIN) * aspect * 100
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
- {[...doc.nodes].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
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 === 'dropdown' ? ctrl.optionsFor(node) : undefined}
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
  )
@@ -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 ───────────────────────────────────────────────────────────────