@xeplr/ui-factory 1.0.6 → 1.0.8

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,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-choices { display: flex; gap: calc(6 * var(--xf-u)) calc(16 * var(--xf-u)); overflow: auto; min-height: 0; }
78
+ .xeplr-factory-choices--vertical { flex-direction: column; }
79
+ .xeplr-factory-choices--horizontal { flex-direction: row; flex-wrap: wrap; align-items: center; }
80
+ .xeplr-factory-choice { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
81
+ .xeplr-factory-choice 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 ───────────────────────────────────────────────────────────────
package/src/generate.js CHANGED
@@ -23,14 +23,17 @@
23
23
  // The result is an ordinary document, checked before it is returned; from
24
24
  // there it is edited like any other.
25
25
 
26
- import { CONTROLS } from './controls.js'
27
- import { createScreen, addControl, MARGIN, GAP, slugify, camelName } from './document.js'
26
+ import { CONTROLS, CHOICE_TYPES } from './controls.js'
27
+ import { createScreen, addControl, MARGIN, GAP, slugify, camelName, stepsOf } from './document.js'
28
28
  import { assertValidDocument } from './validateDocument.js'
29
29
 
30
30
  /** Heights by control, as page fractions (a page is as tall as it is wide). */
31
31
  export const SPEC_HEIGHTS = {
32
- text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
32
+ text: 0.08, number: 0.08, date: 0.08, datetime: 0.08, dropdown: 0.08,
33
+ file: 0.1, stepper: 0.09,
33
34
  textarea: 0.18,
35
+ // A group is as tall as its options make it — see heightOf().
36
+ radio: 0.14, multiselect: 0.18,
34
37
  checkbox: 0.05,
35
38
  list: 0.42,
36
39
  label: { heading: 0.07, subheading: 0.055, text: 0.045 }
@@ -42,7 +45,7 @@ export const SPEC_HEIGHTS = {
42
45
  */
43
46
  export const ROW_GAP = 0.04
44
47
 
45
- const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant', 'style']
48
+ const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'layout', 'accept', 'maxSize', 'step', 'steps', 'showNumbers', 'width', 'text', 'variant', 'style']
46
49
  const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
47
50
  const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
48
51
 
@@ -79,20 +82,48 @@ export function screenFromSpec(spec, controls = CONTROLS) {
79
82
  let col = 0
80
83
  let rowH = 0
81
84
 
85
+ // A stepper on the screen, and where each of its steps starts: every step is
86
+ // laid out from just under the bar, so they sit on top of one another rather
87
+ // than running down a page as long as all of them put together.
88
+ let stepper = null
89
+ let stepTop = 0
90
+ let step = null
91
+
82
92
  const place = (type, props, width) => {
83
93
  const h = heightOf(type, props)
84
- const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list'
94
+ const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list' || type === 'stepper'
95
+ let node
85
96
  if (isFull) {
86
97
  if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
87
- ;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
98
+ ;({ document: doc, node } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props, step }, controls))
88
99
  y += h + ROW_GAP
89
- return
100
+ } else {
101
+ const x = MARGIN + col * (colW + GAP)
102
+ ;({ document: doc, node } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props, step }, controls))
103
+ rowH = Math.max(rowH, h)
104
+ col++
105
+ if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
106
+ }
107
+ if (type === 'stepper') {
108
+ stepper = node
109
+ stepTop = y
110
+ step = { of: node.id, index: 0 }
90
111
  }
91
- const x = MARGIN + col * (colW + GAP)
92
- ;({ document: doc } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props }, controls))
93
- rowH = Math.max(rowH, h)
94
- col++
95
- if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
112
+ return node
113
+ }
114
+
115
+ /** Move the layout to a step: back to the top, under the bar. */
116
+ const openStep = (which) => {
117
+ const steps = stepsOf(stepper)
118
+ const index = typeof which === 'number' ? which - 1 : steps.findIndex((s) => s.key === which || s.label === which)
119
+ if (index < 0 || index >= steps.length) {
120
+ throw new Error(`screenFromSpec: step ${JSON.stringify(which)} is not one of ${steps.map((s) => s.label).join(', ')}`)
121
+ }
122
+ if (step && step.index === index) return
123
+ step = { of: stepper.id, index }
124
+ y = stepTop
125
+ col = 0
126
+ rowH = 0
96
127
  }
97
128
 
98
129
  if (spec.heading !== false) {
@@ -106,6 +137,10 @@ export function screenFromSpec(spec, controls = CONTROLS) {
106
137
  const type = field.type || 'text'
107
138
  if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control — one of: ${Object.keys(controls).join(', ')}`)
108
139
  if (field.width !== undefined && field.width !== 'half' && field.width !== 'full') throw new Error(`screenFromSpec: ${at}.width must be "half" or "full"`)
140
+ if (field.step !== undefined) {
141
+ if (!stepper) throw new Error(`screenFromSpec: ${at}.step needs a stepper before it — { "type": "stepper", "steps": [...] }`)
142
+ openStep(field.step)
143
+ }
109
144
  place(type, propsFromField(type, field, at), field.width)
110
145
  })
111
146
 
@@ -123,6 +158,18 @@ export function screenFromSpec(spec, controls = CONTROLS) {
123
158
  }
124
159
 
125
160
  function propsFromField(type, field, at) {
161
+ if (type === 'stepper') {
162
+ const steps = field.steps
163
+ if (!Array.isArray(steps) || steps.length < 2) throw new Error(`screenFromSpec: ${at} is a stepper — give it steps: ["Connect", "Transform", …]`)
164
+ const props = {
165
+ steps: steps.map((s, i) => (typeof s === 'string'
166
+ ? { key: slugify(s, '_') || `step_${i + 1}`, label: s }
167
+ : s))
168
+ }
169
+ if (field.showNumbers !== undefined) props.showNumbers = field.showNumbers
170
+ if (field.style) props.style = field.style
171
+ return props
172
+ }
126
173
  if (type === 'label') {
127
174
  const props = { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
128
175
  if (field.style) props.style = field.style
@@ -134,17 +181,23 @@ function propsFromField(type, field, at) {
134
181
  if (field[k] !== undefined) props[k] = field[k]
135
182
  })
136
183
  if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
137
- if (type === 'dropdown') {
138
- props.data = dataFromField(field, at)
139
- // A dropdown reading another table stores that row's id: name the column
184
+ if (CHOICE_TYPES.includes(type)) {
185
+ props.data = dataFromField(field, at, type)
186
+ if (field.layout !== undefined && type !== 'dropdown') props.layout = field.layout
187
+ // A field reading another table stores that row's id: name the column
140
188
  // for what it holds — "Department" → departmentId, a foreign key.
141
- if (props.data.source === 'table' && props.name === undefined) props.name = camelName(field.label) + 'Id'
189
+ if (props.data.source === 'table' && props.name === undefined && type !== 'multiselect') props.name = camelName(field.label) + 'Id'
190
+ }
191
+ else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to ${CHOICE_TYPES.join(', ')}`)
192
+ if (type === 'file') {
193
+ ;['accept', 'maxSize'].forEach((k) => { if (field[k] !== undefined) props[k] = field[k] })
194
+ } else if (field.accept !== undefined || field.maxSize !== undefined) {
195
+ throw new Error(`screenFromSpec: ${at} — accept/maxSize only apply to a file`)
142
196
  }
143
- else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
144
197
  return props
145
198
  }
146
199
 
147
- function dataFromField(field, at) {
200
+ function dataFromField(field, at, type) {
148
201
  if (field.data) return field.data
149
202
  if (field.table) return { source: 'table', table: field.table }
150
203
  if (Array.isArray(field.options)) {
@@ -155,15 +208,26 @@ function dataFromField(field, at) {
155
208
  : o))
156
209
  }
157
210
  }
158
- throw new Error(`screenFromSpec: ${at} is a dropdown — give it options: [...] or table: "<name>"`)
211
+ throw new Error(`screenFromSpec: ${at} is a ${type} — give it options: [...] or table: "<name>"`)
159
212
  }
160
213
 
161
214
  function heightOf(type, props) {
162
215
  const h = SPEC_HEIGHTS[type]
163
216
  if (type === 'label') return h[props.variant] || h.text
217
+ // A group of options grows with them: the label, then a line per option
218
+ // (or one line for all of them, side by side).
219
+ if (type === 'radio' || type === 'multiselect') {
220
+ const n = props.data?.source === 'static' ? (props.data.options || []).length : 4
221
+ const lines = props.layout === 'horizontal' ? 1 : Math.max(n, 1)
222
+ return round(LABEL_H + lines * OPTION_H)
223
+ }
164
224
  return h || 0.08
165
225
  }
166
226
 
227
+ /** A field's label, and one option, as page fractions. */
228
+ const LABEL_H = 0.035
229
+ const OPTION_H = 0.03
230
+
167
231
  function unknownKeys(obj, allowed, at) {
168
232
  const extra = Object.keys(obj).filter((k) => !allowed.includes(k))
169
233
  if (extra.length) throw new Error(`screenFromSpec: ${at} has unknown key(s) ${extra.join(', ')} — allowed: ${allowed.join(', ')}`)
package/src/model.js CHANGED
@@ -3,14 +3,15 @@
3
3
  //
4
4
  // import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
5
5
 
6
- export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, controlGroups, withControls } from './controls.js'
6
+ export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, controlGroups, withControls } from './controls.js'
7
7
  export {
8
8
  DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
9
9
  createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
10
- inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
10
+ inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
11
+ steppers, stepsOf, stepOf, setNodeStep, nodesForSteps
11
12
  } from './document.js'
12
13
  export { validateDocument, assertValidDocument } from './validateDocument.js'
13
- export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, saveState, recordValues, displayValue, listColumns, listSource } from './values.js'
14
+ export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
14
15
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
15
16
  export { scaffoldEntity } from './scaffold.js'
16
17
  export { getAtPath, setAtPath } from './propertyPath.js'
package/src/remote.js CHANGED
@@ -43,6 +43,27 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
43
43
  return res && Array.isArray(res.dataArray) ? res.dataArray : []
44
44
  }
45
45
 
46
+ /**
47
+ * The same call, with a body the browser must describe itself (a FormData's
48
+ * multipart boundary). Nothing sets Content-Type here — see authFetch.
49
+ */
50
+ async function send(method, path, body) {
51
+ let res
52
+ try {
53
+ res = await doFetch(`${base}/factory${path}`, { method, body })
54
+ } catch (err) {
55
+ if (err && err.body !== undefined && err.status) throw failure(method, path, err.status, err.body, err.message)
56
+ throw err
57
+ }
58
+ if (res && typeof res.json === 'function' && typeof res.ok === 'boolean') {
59
+ let json = null
60
+ try { json = await res.json() } catch (_) { /* not JSON — reported below */ }
61
+ if (!res.ok) throw failure(method, path, res.status, json)
62
+ return json ? json.dataArray : []
63
+ }
64
+ return res && Array.isArray(res.dataArray) ? res.dataArray : []
65
+ }
66
+
46
67
  function failure(method, path, status, json, fallback) {
47
68
  const err = new Error((json && json.message) || fallback || `${method} ${path} failed (${status})`)
48
69
  err.status = status
@@ -77,9 +98,18 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
77
98
  fetchRecord: async ({ screen, id }) => one(await call('GET', `/records/${enc(screen)}/${enc(id)}`)),
78
99
  onSave: async (values, { id, screen }) => one(await call('POST', `/records/${enc(screen)}/save`, { id, values })),
79
100
  onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
80
- fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`)
101
+ fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`),
102
+
103
+ // A file field's upload. The answer's `path` is what the record stores;
104
+ // the file itself is read back through the same route, with auth.
105
+ uploadFile: async (file, { screen, field }) => {
106
+ const form = new FormData()
107
+ form.append('file', file)
108
+ return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
109
+ },
110
+ fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
81
111
  }
82
- api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions }
112
+ api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl }
83
113
  api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords }
84
114
  return api
85
115
  }
@@ -51,10 +51,11 @@ export const MAX_IDENTIFIER = 63
51
51
 
52
52
  const TEXT_DEFAULT_LENGTH = 255
53
53
  const OPTION_MIN_LENGTH = 50
54
+ const FILE_PATH_LENGTH = 255
54
55
 
55
56
  /**
56
57
  * The column a field is stored in.
57
- * @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'boolean', length?, references?, notNull?, default? }}
58
+ * @returns {{ name, type: 'varchar'|'text'|'integer'|'numeric'|'date'|'timestamp'|'boolean', length?, references?, notNull?, default? }}
58
59
  */
59
60
  export function columnForField(node) {
60
61
  const p = node.props || {}
@@ -69,9 +70,18 @@ export function columnForField(node) {
69
70
  return { ...base, type: v.integer ? 'integer' : 'numeric' }
70
71
  case 'date':
71
72
  return { ...base, type: 'date' }
73
+ case 'datetime':
74
+ return { ...base, type: 'timestamp' }
75
+ case 'file':
76
+ // The stored file's path, as the upload answered with it.
77
+ return { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
78
+ case 'multiselect':
79
+ // Every chosen id in one text column, separated by commas.
80
+ return { ...base, type: 'text' }
72
81
  case 'checkbox':
73
82
  return { ...base, type: 'boolean', notNull: true, default: 'false' }
74
- case 'dropdown': {
83
+ case 'dropdown':
84
+ case 'radio': {
75
85
  const data = p.data || {}
76
86
  if (data.source === 'table') {
77
87
  // A real foreign key to the other entity's id.
@@ -118,6 +128,8 @@ export function widening(from, to) {
118
128
  }
119
129
  if (from.type === 'varchar' && to.type === 'text') return { ok: true }
120
130
  if (from.type === 'integer' && to.type === 'numeric') return { ok: true }
131
+ // A date becomes that day at midnight; the other way round would lose the time.
132
+ if (from.type === 'date' && to.type === 'timestamp') return { ok: true }
121
133
  return { ok: false, reason: `${describe(from)} → ${describe(to)} is a different kind of value` }
122
134
  }
123
135
 
@@ -250,6 +262,8 @@ export function columnFromDatabase(c) {
250
262
  case 'int4': return { ...base, type: 'integer' }
251
263
  case 'numeric': return { ...base, type: 'numeric' }
252
264
  case 'date': return { ...base, type: 'date' }
265
+ case 'timestamp':
266
+ case 'timestamptz': return { ...base, type: 'timestamp' }
253
267
  case 'bool': return { ...base, type: 'boolean' }
254
268
  // Anything else (a timestamp, a bigint someone chose) is not a type a field
255
269
  // makes — so any change to it is refused rather than guessed at.