@xeplr/ui-factory 1.2.0 → 1.4.0

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,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { getAtPath } from '../propertyPath.js'
3
3
  import { FIELD_PRESETS, presetFor } from '../presets.js'
4
- import { slugify, inputNodes } from '../document.js'
4
+ import { slugify, inputNodes, paramsText } from '../document.js'
5
5
  import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
6
6
 
7
7
  // The selected control's properties, GENERATED from its entry in controls.js:
@@ -58,7 +58,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
58
58
  )
59
59
  }
60
60
 
61
- export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, selectionCount }) {
61
+ export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
62
62
  if (selectionCount > 1) {
63
63
  return (
64
64
  <aside className="xeplr-factory-panel">
@@ -109,6 +109,7 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
109
109
  node={node}
110
110
  doc={doc}
111
111
  errors={errs}
112
+ onMakeChildTable={onMakeChildTable}
112
113
  />
113
114
  {locked
114
115
  ? <div className="xeplr-factory-prop-help">Saved as a column of <code>{doc?.source}</code> — renaming it would leave its data behind. A rename is a migration.</div>
@@ -149,7 +150,15 @@ function ScreenPanel({ doc, errors, tables, onChange }) {
149
150
  return (
150
151
  <div key={field.path} className={`xeplr-factory-prop${msg ? ' has-error' : ''}`}>
151
152
  {field.type !== 'toggle' && field.type !== 'toggleValue' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
152
- <Editor id={id} field={field} value={getAtPath(doc, field.path)} onChange={(v) => onChange(field.path, v)} tables={tables} errors={[]} {...extra} />
153
+ <Editor
154
+ id={id}
155
+ field={field}
156
+ value={field.path === 'paramsLine' ? paramsText(doc) : getAtPath(doc, field.path)}
157
+ onChange={(v) => onChange(field.path, v)}
158
+ tables={tables}
159
+ errors={[]}
160
+ {...extra}
161
+ />
153
162
  {field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
154
163
  {msg && <div className="xeplr-factory-prop-error">{msg}</div>}
155
164
  </div>
@@ -161,9 +170,48 @@ function ScreenPanel({ doc, errors, tables, onChange }) {
161
170
  <span className="xeplr-factory-panel-type">Screen</span>
162
171
  </header>
163
172
  <p className="xeplr-factory-panel-empty">Select a control to change it, or drag one in from the left.</p>
173
+ <fieldset className="xeplr-factory-group">
174
+ <legend>About</legend>
175
+ {/* WHAT IT IS FOR. When a lot of people can make forms, a lot of
176
+ people make the same one — and the fields say what a form holds,
177
+ never what it was for or whether this is the one still in use. */}
178
+ {row({
179
+ path: 'description',
180
+ label: 'What this form is for',
181
+ type: 'textarea',
182
+ help: 'One sentence. It is what tells this apart from the other three called the same thing.'
183
+ }, TextareaEditor)}
184
+ </fieldset>
164
185
  <fieldset className="xeplr-factory-group">
165
186
  <legend>Data</legend>
166
187
  {row({ path: 'source', label: 'Saves to', type: 'table', help: 'The table this screen\'s records are saved in' }, TableEditor)}
188
+ {/* A MASTER is a list other forms point at — departments, statuses,
189
+ countries. A flag on an ordinary form, not a kind of its own. */}
190
+ {row({
191
+ path: 'master',
192
+ label: 'This is a master',
193
+ type: 'toggle',
194
+ help: 'A list other forms choose from. Masters are kept together in the admin section.'
195
+ }, EDITORS.toggle || TextEditor)}
196
+ {doc.master && row({
197
+ path: 'labelField',
198
+ label: 'Shown as',
199
+ type: 'select',
200
+ options: [{ value: '', label: '— choose a field —' }].concat(
201
+ inputNodes(doc).map((n) => ({ value: n.props.name, label: n.props.label || n.props.name }))
202
+ ),
203
+ help: 'What a picker shows for a record. Said once here, not on every dropdown.'
204
+ }, EDITORS.select || TextEditor)}
205
+ {/* WHAT THIS FORM IS CALLED WITH. One line, because that is the whole
206
+ question — "this form needs contentId, age, name". A field then
207
+ binds to one with {params.contentId} as its default, the same
208
+ brace used in a workflow step or a report. */}
209
+ {row({
210
+ path: 'paramsLine',
211
+ label: 'Inputs',
212
+ type: 'text',
213
+ help: 'Names, comma separated. A field takes one with {params.name} as its default.'
214
+ }, ListLineEditor)}
167
215
  </fieldset>
168
216
  <fieldset className="xeplr-factory-group">
169
217
  <legend>Size</legend>
@@ -181,6 +229,46 @@ function LockedEditor({ id, value }) {
181
229
  return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" value={value ?? ''} readOnly aria-readonly="true" />
182
230
  }
183
231
 
232
+ /**
233
+ * A LIST TYPED AS A LINE — the screen's inputs.
234
+ *
235
+ * Two bugs live here, and this shape is what avoids both.
236
+ *
237
+ * It cannot be a plain TextEditor: what is STORED is a list and what is SHOWN
238
+ * is that list joined again, so a comma is parsed away the moment it is typed
239
+ * ("a," → ["a"] → "a") and the box refuses to let anybody start a second name.
240
+ *
241
+ * And it cannot commit on BLUR, which was the first fix: pressing Publish is
242
+ * one gesture, so the blur and the click race, and a name typed just before
243
+ * publishing was not in the document the publish read. The screen went out
244
+ * with no inputs and nothing said why.
245
+ *
246
+ * So the text is kept HERE for display, and the document is told on every
247
+ * keystroke. Nothing is ever waiting to be committed, and the line somebody
248
+ * is typing is never rewritten under their fingers.
249
+ */
250
+ function ListLineEditor({ id, value, onChange }) {
251
+ const [text, setText] = useState(value ?? '')
252
+ const [typing, setTyping] = useState(false)
253
+
254
+ // Taken from the document again when it changes underneath — a different
255
+ // screen, an undo — unless the box is being typed in right now, which is
256
+ // the only thing this local copy exists for.
257
+ useEffect(() => { if (!typing) setText(value ?? '') }, [value, typing])
258
+
259
+ return (
260
+ <input
261
+ id={id}
262
+ className="xeplr-factory-prop-input"
263
+ type="text"
264
+ value={text}
265
+ onFocus={() => setTyping(true)}
266
+ onChange={(e) => { setText(e.target.value); onChange(e.target.value) }}
267
+ onBlur={(e) => { setTyping(false); onChange(e.target.value) }}
268
+ />
269
+ )
270
+ }
271
+
184
272
  function TextEditor({ id, value, onChange }) {
185
273
  return <input id={id} className="xeplr-factory-prop-input" type="text" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
186
274
  }
@@ -282,12 +370,113 @@ function ToggleValueEditor({ id, field, value, onChange }) {
282
370
  }
283
371
 
284
372
  /** A table name: picked from the app's tables when it lists them, typed otherwise. */
373
+ /**
374
+ * WHERE A DROPDOWN'S OPTIONS COME FROM.
375
+ *
376
+ * MASTERS FIRST, by the name a person knows them as — "Departments", not
377
+ * "departments" — because those are the lists that exist to be pointed at. A
378
+ * flat list of thirty raw table names is one nobody can read, and nothing in
379
+ * it says which tables were meant for this.
380
+ *
381
+ * Everything else is still underneath. A dropdown may legitimately read from
382
+ * any table a published screen reads, and hiding those would replace a
383
+ * confusing list with a dead end.
384
+ *
385
+ * Two groups in a plain <select>, not a combobox: typing jumps to an option
386
+ * in every browser, which is the searching somebody actually wants here, and
387
+ * a widget of our own would have to reimplement the keyboard to match.
388
+ */
389
+ /**
390
+ * The same picker, plus a way out of the dead end.
391
+ *
392
+ * A sub-table points at a table that usually does not exist yet, and the
393
+ * honest answer to "which table?" at that moment is "the one I am about to
394
+ * make". Without this the designer has to be left, a form built, and the
395
+ * thought recovered on the way back.
396
+ *
397
+ * Only on a LIST, and only when the host can make one: it is a server act.
398
+ */
399
+ function ListTableEditor({ id, value, onChange, tables, node, doc, onMakeChildTable }) {
400
+ const [making, setMaking] = useState(false)
401
+ const [name, setName] = useState('')
402
+ const [busy, setBusy] = useState(false)
403
+ const [error, setError] = useState(null)
404
+ const canMake = Boolean(onMakeChildTable && doc && doc.source && node)
405
+
406
+ const make = async () => {
407
+ if (!name.trim() || busy) return
408
+ setBusy(true)
409
+ try {
410
+ await onMakeChildTable(node.id, name.trim())
411
+ setMaking(false)
412
+ setName('')
413
+ setError(null)
414
+ } catch (err) {
415
+ setError(err.message)
416
+ } finally {
417
+ setBusy(false)
418
+ }
419
+ }
420
+
421
+ if (making) {
422
+ return (
423
+ <div>
424
+ <input
425
+ id={id}
426
+ className="xeplr-factory-prop-input"
427
+ autoFocus
428
+ value={name}
429
+ placeholder="Characteristics"
430
+ onChange={(e) => setName(e.target.value)}
431
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); make() } }}
432
+ />
433
+ <div className="xeplr-factory-prop-help">
434
+ Makes a table of rows belonging to this one, and points this list at it.
435
+ </div>
436
+ {error && <div className="xeplr-factory-prop-error">{error}</div>}
437
+ <button type="button" className="xeplr-factory-primary" disabled={!name.trim() || busy} onClick={make}>
438
+ {busy ? 'Making it…' : 'Make the table'}
439
+ </button>
440
+ <button type="button" className="xeplr-factory-secondary" onClick={() => { setMaking(false); setError(null) }}>Cancel</button>
441
+ </div>
442
+ )
443
+ }
444
+
445
+ return (
446
+ <div>
447
+ <TableEditor id={id} value={value} onChange={onChange} tables={tables} />
448
+ {canMake && (
449
+ <button type="button" className="xeplr-factory-secondary" onClick={() => setMaking(true)}>
450
+ New table…
451
+ </button>
452
+ )}
453
+ </div>
454
+ )
455
+ }
456
+
285
457
  function TableEditor({ id, value, onChange, tables }) {
286
- if (tables && tables.items.length > 0) {
458
+ const items = (tables && tables.items) || []
459
+ if (items.length > 0) {
460
+ const masters = items.filter((t) => t.master)
461
+ const rest = items.filter((t) => !t.master)
462
+ // A SELECT WHOSE VALUE IS NOT AN OPTION SHOWS NOTHING, which reads as the
463
+ // setting having been lost. It has not: a table made a moment ago is not
464
+ // in a list fetched when the page opened, and a table whose screens were
465
+ // deleted is still what this list names.
466
+ const known = items.some((t) => String(t.id) === String(value || ''))
467
+ const stray = value && !known ? { id: value, name: value } : null
468
+ const option = (t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>
469
+
287
470
  return (
288
471
  <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
289
472
  <option value="">{tables.loading ? 'Loading tables…' : '—'}</option>
290
- {tables.items.map((t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>)}
473
+ {stray && <option value={String(stray.id)}>{stray.name}</option>}
474
+ {masters.length > 0 && <optgroup label="Masters">{masters.map(option)}</optgroup>}
475
+ {/* Named as what they are, so it reads as "the rest" rather than as a
476
+ second list of the same kind of thing. */}
477
+ {rest.length > 0 && (
478
+ <optgroup label={masters.length > 0 ? 'Other tables' : 'Tables'}>{rest.map(option)}</optgroup>
479
+ )}
291
480
  </select>
292
481
  )
293
482
  }
@@ -301,7 +490,17 @@ function TableEditor({ id, value, onChange, tables }) {
301
490
  */
302
491
  function ColumnsEditor({ value, onChange, doc, node, screens }) {
303
492
  const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
304
- const from = doc && inputNodes(doc).length ? doc : editDoc
493
+
494
+ // A LIST OF ANOTHER TABLE IS NOT DESCRIBED BY THIS SCREEN. Pointing a list
495
+ // at material_characteristics and being offered the MATERIAL's fields is
496
+ // worse than being offered nothing: every one of them ticks a column that
497
+ // will be blank in every row.
498
+ //
499
+ // The edit screen for those rows is the only description to hand here — a
500
+ // host that did not provide it leaves this empty, and the list falls back
501
+ // to the columns the rows themselves have (see listColumns).
502
+ const foreign = node && node.props.source && doc && node.props.source !== doc.source
503
+ const from = foreign ? editDoc : (doc && inputNodes(doc).length ? doc : editDoc)
305
504
  const fields = from
306
505
  ? inputNodes(from).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
307
506
  : (Array.isArray(value) ? value : [])
@@ -313,7 +512,18 @@ function ColumnsEditor({ value, onChange, doc, node, screens }) {
313
512
  // All of them again is the same as "not set" — keep the document clean.
314
513
  onChange(next.length === fields.length || next.length === 0 ? undefined : next.map((c) => ({ field: c.field, label: (current.find((x) => x.field === c.field) || c).label })))
315
514
  }
316
- if (!fields.length) return <div className="xeplr-factory-prop-help">Add fields to the screen first — the list shows them as columns.</div>
515
+ if (!fields.length) {
516
+ // Two different nothings, and saying "add fields to this screen" to
517
+ // somebody pointing a list at another table sends them to fix the wrong
518
+ // thing.
519
+ return (
520
+ <div className="xeplr-factory-prop-help">
521
+ {foreign
522
+ ? 'Every column of ' + node.props.source + '. Set "Edit in" to that table\'s form to choose.'
523
+ : 'Add fields to the screen first — the list shows them as columns.'}
524
+ </div>
525
+ )
526
+ }
317
527
  return (
318
528
  <div className="xeplr-factory-checklist">
319
529
  {fields.map((f) => (
@@ -340,6 +550,24 @@ function ScreenEditor({ id, value, onChange, screens, doc }) {
340
550
  return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="employee_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
341
551
  }
342
552
 
553
+ /**
554
+ * A form to draw inside this one: the screens with fields of their own (a list
555
+ * screen has nothing to fill in), never this one — a form cannot hold itself.
556
+ */
557
+ function FormEditor({ id, value, onChange, screens, doc }) {
558
+ const forms = (screens || []).filter((s) => (!doc || s.id !== doc.id)
559
+ && (!s.document || (s.document.nodes || []).some((n) => n.props && n.props.name)))
560
+ if (forms.length) {
561
+ return (
562
+ <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
563
+ <option value="">— choose a form</option>
564
+ {forms.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
565
+ </select>
566
+ )
567
+ }
568
+ return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="bank_details_edit" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
569
+ }
570
+
343
571
  const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
344
572
 
345
573
  function ActionsEditor({ value, onChange }) {
@@ -522,7 +750,9 @@ export const EDITORS = {
522
750
  color: ColorEditor,
523
751
  table: TableEditor,
524
752
  screen: ScreenEditor,
753
+ form: FormEditor,
525
754
  columns: ColumnsEditor,
755
+ listTable: ListTableEditor,
526
756
  actions: ActionsEditor,
527
757
  dataSource: DataSourceEditor
528
758
  }
@@ -124,7 +124,7 @@ function SaveButtons({ ctrl }) {
124
124
  )
125
125
  }
126
126
 
127
- export default function ScreenSample({ ctrl, className, style, renderPopup }) {
127
+ export default function ScreenSample({ ctrl, className, style, renderPopup, renderSubform }) {
128
128
  const { document: doc } = ctrl
129
129
 
130
130
  if (ctrl.documentErrors.length) {
@@ -147,20 +147,23 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
147
147
  // A screen with no fields of its own (a list screen) has nothing to save —
148
148
  // and no sheet: its list is the card (factory.css, --bare).
149
149
  const hasFields = fieldNodes.length > 0
150
- const status = hasFields ? <SaveStatus ctrl={ctrl} /> : null
150
+ // A form inside another has no footer of its own: the host's Save saves it.
151
+ const footer = !ctrl.embedded
152
+ const status = hasFields && footer ? <SaveStatus ctrl={ctrl} /> : null
153
+ // Nor a <form> of its own — one form inside another is not valid HTML.
154
+ const Page = ctrl.embedded ? 'div' : 'form'
151
155
  const steppers = ctrl.steps.nodes
152
156
 
153
157
  return (
154
158
  <div
155
- className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (className ? ' ' + className : '')}
159
+ className={'xeplr-factory-screen' + (hasFields ? '' : ' xeplr-factory-screen--bare') + (ctrl.embedded ? ' xeplr-factory-screen--embedded' : '') + (className ? ' ' + className : '')}
156
160
  style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
157
161
  >
158
- <form
162
+ <Page
159
163
  className="xeplr-factory-page"
160
164
  style={{ height: `${pageHeight}cqw` }}
161
165
  // Never a submit: Enter posts nothing and saves nothing. Save is a button.
162
- onSubmit={(e) => e.preventDefault()}
163
- noValidate
166
+ {...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
164
167
  aria-label={doc.name}
165
168
  >
166
169
  {[...shown].sort((a, b) => (a.z || 0) - (b.z || 0)).map((node) => (
@@ -206,19 +209,22 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
206
209
  onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
207
210
  onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : (ctrl.confirmDiscard() && ctrl.newRecord()))
208
211
  } : undefined}
212
+ // A form inside this one: the page draws it (renderSubform).
213
+ subform={node.type === 'subform' ? { sub: ctrl.subformFor(node), render: renderSubform } : undefined}
209
214
  />
210
215
  </div>
211
216
  ))}
212
- </form>
217
+ </Page>
213
218
  {steppers.map((node, i) => {
214
219
  const last = i === steppers.length - 1
215
220
  return (
221
+ // An embedded form keeps its Back and Next; only Save is the host's.
216
222
  <StepNav key={node.id} ctrl={ctrl} node={node} status={last ? status : null}>
217
- {last && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
223
+ {last && footer && (hasFields || ctrl.doneLabel) && <SaveButtons ctrl={ctrl} />}
218
224
  </StepNav>
219
225
  )
220
226
  })}
221
- {!steppers.length && (hasFields || ctrl.doneLabel) && (
227
+ {footer && !steppers.length && (hasFields || ctrl.doneLabel) && (
222
228
  <div className="xeplr-factory-footer">
223
229
  <div className="xeplr-factory-footer-mid">{status}</div>
224
230
  <SaveButtons ctrl={ctrl} />
@@ -98,6 +98,31 @@
98
98
  .xeplr-factory-input:hover { border-color: color-mix(in srgb, var(--xf-accent) 45%, var(--xf-field-border)); }
99
99
  .xeplr-factory-textarea { padding-block: calc(8 * var(--xf-u)); resize: none; }
100
100
  .xeplr-factory-input:focus { outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 25%, transparent); }
101
+
102
+ /* ── Dropdowns ─────────────────────────────────────────────────────────────
103
+ Every <select> the factory draws — on a screen, in the builder's panels, in
104
+ the flow designer — gets our own chevron, inset from the edge with room
105
+ kept for it. The browser's own sat against the border and in a different
106
+ place per platform. Drawn with two gradients rather than an SVG, so it is
107
+ the theme's muted ink and follows a dark theme. Only background-image and
108
+ its longhands are set: each box keeps its own colour, and a control's
109
+ Background (boxStyle) is a background-COLOR so it cannot wipe the arrow. */
110
+ :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]) {
111
+ -webkit-appearance: none;
112
+ appearance: none;
113
+ /* Two thin diagonals side by side — each corner to corner of its own small
114
+ square — meet at the bottom as a ∨. */
115
+ background-image:
116
+ linear-gradient(45deg, transparent 42%, var(--xf-muted) 42%, var(--xf-muted) 58%, transparent 58%),
117
+ linear-gradient(135deg, transparent 42%, var(--xf-muted) 42%, var(--xf-muted) 58%, transparent 58%);
118
+ background-position: calc(100% - 16px) 50%, calc(100% - 10px) 50%;
119
+ background-size: 6px 6px, 6px 6px;
120
+ background-repeat: no-repeat;
121
+ padding-right: 30px;
122
+ text-overflow: ellipsis;
123
+ cursor: pointer;
124
+ }
125
+ :is(.xeplr-factory-screen, .xeplr-factory-canvas, .xeplr-factory-flow-builder, .xeplr-factory-builder, .xeplr-factory-modal-backdrop) select:not([multiple]):disabled { cursor: default; opacity: .6; }
101
126
  .xeplr-factory-field.has-error .xeplr-factory-input { border-color: var(--xf-danger); }
102
127
  .xeplr-factory-error {
103
128
  /* In the gap BELOW the field, out of flow: a message appearing must not
@@ -216,6 +241,19 @@
216
241
  .xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
217
242
  .xeplr-factory-text--text { color: var(--xf-muted); }
218
243
 
244
+ /* ── a form inside a form ────────────────────────────────────────────────
245
+ The inner form is drawn as itself, without its own sheet or footer: it is
246
+ part of this page, and its Save is this page's Save. Its box scrolls when
247
+ the form is taller than the space it was given. */
248
+ .xeplr-factory-subform { display: flex; flex-direction: column; gap: 8px; width: 100%; height: 100%; box-sizing: border-box; overflow: auto; }
249
+ .xeplr-factory-subform-title { font-weight: 600; font-size: 1.05em; color: var(--xf-ink); }
250
+ .xeplr-factory-screen--embedded { border: none; border-radius: 0; background: none; }
251
+ .xeplr-factory-subform--design {
252
+ justify-content: center; align-items: center; text-align: center; padding: 12px;
253
+ border: 1px dashed var(--xf-border); border-radius: 10px; color: var(--xf-muted);
254
+ }
255
+ .xeplr-factory-subform-what code { color: var(--xf-ink); }
256
+
219
257
  /* ── list ───────────────────────────────────────────────────────────────── */
220
258
  .xeplr-factory-list {
221
259
  display: flex; flex-direction: column; width: 100%; box-sizing: border-box; overflow: auto;
@@ -310,6 +348,7 @@
310
348
  .xeplr-factory-palette-icon--file { border-radius: 2px; border-top: 3px solid var(--xf-muted); height: 12px; width: 10px; }
311
349
  .xeplr-factory-palette-icon--stepper { height: 8px; border-radius: 999px; background: linear-gradient(to right, var(--xf-muted) 0 30%, transparent 30%); border: none; }
312
350
  .xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
351
+ .xeplr-factory-palette-icon--subform { border-style: double; border-width: 3px; height: 11px; }
313
352
  .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; }
314
353
  .xeplr-factory-palette-icon--textarea { height: 14px; }
315
354
 
@@ -460,3 +499,28 @@
460
499
  padding: 16px; border-radius: 8px; border: 1px solid var(--xf-border);
461
500
  background: var(--xf-surface); color: var(--xf-ink);
462
501
  }
502
+
503
+
504
+ /* ── a child table, designed where it was made ─────────────────────────── */
505
+ /* Wide, because what is inside is a whole designer: palette, canvas and
506
+ property panel. A form builder in a dialog box is a canvas nobody can
507
+ work on. */
508
+ .xeplr-factory-modal--wide {
509
+ width: min(1600px, 96vw);
510
+ height: min(1000px, 92vh);
511
+ display: flex;
512
+ flex-direction: column;
513
+ overflow: hidden;
514
+ }
515
+ .xeplr-factory-modal-head {
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 10px;
519
+ padding: 10px 14px;
520
+ border-bottom: 1px solid var(--xf-border);
521
+ }
522
+ .xeplr-factory-modal-spacer { flex: 1; }
523
+ /* hidden, not auto: the builder inside manages its own panes */
524
+ .xeplr-factory-modal-body { flex: 1; min-height: 0; overflow: hidden; }
525
+ .xeplr-factory-modal-body > * { height: 100%; }
526
+ .xeplr-factory-builder--nested { min-height: 0; }
@@ -32,7 +32,7 @@ export function boxStyle(style) {
32
32
  fontStyle: s.fontStyle,
33
33
  textAlign: s.textAlign,
34
34
  color: s.color,
35
- background: s.background,
35
+ backgroundColor: s.background,
36
36
  borderColor: s.borderColor,
37
37
  borderWidth: s.borderWidth != null ? `${s.borderWidth}px` : undefined,
38
38
  borderStyle: s.borderWidth != null ? 'solid' : undefined,
package/src/document.js CHANGED
@@ -33,6 +33,53 @@ export const MARGIN = 0.04
33
33
  export const GAP = 0.025
34
34
 
35
35
  /** A new, empty screen. */
36
+ /**
37
+ * WHAT A SCREEN IS CALLED WITH.
38
+ *
39
+ * A form can need things it must not ask for: a containment needs the
40
+ * complaint it belongs to, and the person filling it in does not know a
41
+ * twenty-five character id. So a screen declares its INPUTS the way an action
42
+ * declares its inputSchema — one named list, at the screen — and a field
43
+ * binds to one with the same `{token}` this product uses everywhere else:
44
+ *
45
+ * props.default = '{params.complaintId}'
46
+ *
47
+ * A value with a token is a binding; a plain value is a literal. Nothing is
48
+ * marked on the field, because nothing needs to be: the brace already says
49
+ * which it is.
50
+ *
51
+ * Written as a plain list of names, the shortest thing that answers the
52
+ * question — "this form needs contentId, age, name".
53
+ */
54
+ export function screenParams(doc) {
55
+ var raw = (doc && doc.params) || []
56
+ if (typeof raw === 'string') raw = raw.split(',')
57
+ return raw
58
+ .map(function (p) { return typeof p === 'string' ? { name: p.trim() } : p })
59
+ .filter(function (p) { return p && p.name })
60
+ }
61
+
62
+ /** The list as one line, which is how it is typed. */
63
+ export function paramsText(doc) {
64
+ return screenParams(doc).map(function (p) { return p.name }).join(', ')
65
+ }
66
+
67
+ /** One line back to the list, for the panel. */
68
+ export function setScreenParams(doc, text) {
69
+ var names = String(text == null ? '' : text)
70
+ .split(',')
71
+ .map(function (n) { return n.trim() })
72
+ .filter(Boolean)
73
+ var seen = {}
74
+ var params = []
75
+ names.forEach(function (n) {
76
+ if (seen[n]) return
77
+ seen[n] = true
78
+ params.push({ name: n })
79
+ })
80
+ return Object.assign({}, doc, { params: params })
81
+ }
82
+
36
83
  export function createScreen({ name, id, aspect, width, source, style } = {}) {
37
84
  const title = String(name || 'Untitled screen').trim() || 'Untitled screen'
38
85
  const doc = {
@@ -60,6 +107,10 @@ export function renameScreen(doc, name) {
60
107
  */
61
108
  export function setScreenProperty(doc, path, value) {
62
109
  if (path === 'nodes' || path.startsWith('nodes.')) throw new Error('setScreenProperty cannot change nodes')
110
+ // The inputs are TYPED as one line and STORED as a list, so the document
111
+ // stays self-describing — the server, the CLI and a flow read names, not a
112
+ // string somebody has to split again.
113
+ if (path === 'paramsLine') return setScreenParams(doc, value)
63
114
  return setAtPath(doc, path, value)
64
115
  }
65
116
 
package/src/index.js CHANGED
@@ -14,7 +14,9 @@ export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
14
14
  // This app's screens, shaped for another package's designer — see
15
15
  // useScreenSource.js. Two props instead of a page of glue per application.
16
16
  export { useScreenSource } from './useScreenSource.js'
17
- export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf } from './screenSource.js'
17
+ export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf, masterForms, formKeyOf, deleteForm } from './screenSource.js'
18
+ // Making a master: the one form shape every application repeats.
19
+ export { masterSpec, createMaster, possibleParents, singularOf, MASTER_LABEL, MASTER_ORDER, childSpec, createChildTable } from './masters.js'
18
20
  export { useFlowRun } from './useFlowRun.js'
19
21
  export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
20
22