@xeplr/ui-factory 1.0.1 → 1.0.2

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.
@@ -0,0 +1,95 @@
1
+ import { XeplrTable } from '@xeplr/ui-table'
2
+ import { displayValue, listColumns, listSource } from '../values.js'
3
+ import { boxStyle, fieldStyle } from './styles.js'
4
+
5
+ // A list of the saved records, in @xeplr/ui-table.
6
+ //
7
+ // On the canvas (design) it is a still picture of the columns — there are no
8
+ // records while designing. Live, rows come from the host's fetchRecords.
9
+ // Edit and New open the list's edit screen in a popup (`editScreen`), or, for a
10
+ // list on a form, load the row into the form's own fields. Delete removes it.
11
+
12
+ export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor }) {
13
+ const p = node.props || {}
14
+ const columns = listColumns(doc, node)
15
+ const actions = p.actions || ['new', 'edit', 'delete']
16
+ const style = { ...fieldStyle(p.style), ...boxStyle(p.style) }
17
+
18
+ if (design) {
19
+ return (
20
+ <div className="xeplr-factory-list xeplr-factory-list--design" style={style}>
21
+ <div className="xeplr-factory-list-head">
22
+ <span className="xeplr-factory-list-title">{p.title}</span>
23
+ <span className="xeplr-factory-hint">from {listSource(doc, node) || 'no table'}</span>
24
+ {actions.includes('new') && <span className="xeplr-factory-list-new">+ New</span>}
25
+ </div>
26
+ <table className="xeplr-factory-list-mock">
27
+ <thead>
28
+ <tr>
29
+ {columns.map((c) => <th key={c.field}>{c.label}</th>)}
30
+ {(actions.includes('edit') || actions.includes('delete')) && <th />}
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ {[0, 1, 2].map((i) => (
35
+ <tr key={i}>{columns.map((c) => <td key={c.field}><span className="xeplr-factory-list-bar" /></td>)}{(actions.includes('edit') || actions.includes('delete')) && <td />}</tr>
36
+ ))}
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ const byField = Object.fromEntries((fieldNodes || []).map((n) => [n.props.name, n]))
44
+ // What the table shows: a dropdown's NAME and Yes/No — the raw record is kept
45
+ // alongside, so Edit opens exactly what was saved.
46
+ const rows = (list.rows || []).map((rec) => {
47
+ const shown = { __record: rec }
48
+ columns.forEach((c) => {
49
+ const f = byField[c.field]
50
+ shown[c.field] = displayValue(f, rec[c.field], f && f.type === 'dropdown' ? optionsFor(f).items : undefined)
51
+ })
52
+ return shown
53
+ })
54
+
55
+ const rowActions = []
56
+ if (actions.includes('edit')) {
57
+ rowActions.push({ key: 'edit', label: 'Edit', onClick: (row) => onEdit(row.__record) })
58
+ }
59
+ if (actions.includes('delete') && canDelete) {
60
+ rowActions.push({
61
+ key: 'delete',
62
+ label: 'Delete',
63
+ variant: 'danger',
64
+ onClick: (row) => {
65
+ // eslint-disable-next-line no-alert
66
+ if (typeof window === 'undefined' || window.confirm('Delete this record?')) onDelete(row.__record)
67
+ }
68
+ })
69
+ }
70
+
71
+ return (
72
+ <div className="xeplr-factory-list" style={style}>
73
+ <div className="xeplr-factory-list-head">
74
+ <span className="xeplr-factory-list-title">{p.title}</span>
75
+ {list.loading && <span className="xeplr-factory-hint">Loading…</span>}
76
+ {list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
77
+ {actions.includes('new') && (
78
+ <button type="button" className="xeplr-factory-list-new" onClick={onNew}>+ New</button>
79
+ )}
80
+ </div>
81
+ {!list.loading && !list.error && rows.length === 0 ? (
82
+ <div className="xeplr-factory-list-empty">Nothing saved yet.</div>
83
+ ) : (
84
+ <XeplrTable
85
+ data={rows}
86
+ schema={{ 0: { key: listSource(doc, node) || 'records', columns: columns.map((c) => ({ accessor: c.field, header: c.label })) } }}
87
+ pageSize={p.pageSize || 10}
88
+ rowActions={rowActions.length ? rowActions : undefined}
89
+ rowClassName={(row) => (currentId !== null && currentId !== undefined && row.__record && row.__record[recordKey] === currentId ? 'xeplr-factory-row-open' : null)}
90
+ enableFiltering={rows.length > 5}
91
+ />
92
+ )}
93
+ </div>
94
+ )
95
+ }
@@ -1,11 +1,12 @@
1
1
  import { getAtPath } from '../propertyPath.js'
2
- import { slugify } from '../document.js'
2
+ import { slugify, inputNodes } from '../document.js'
3
+ import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS } from '../controls.js'
3
4
 
4
5
  // The selected control's properties, GENERATED from its entry in controls.js:
5
6
  // each field there names a path and an editor type, and this file owns only
6
7
  // what each editor type looks like. A new property is a line in controls.js.
7
8
 
8
- export default function PropertyPanel({ node, control, errors, tables, onChange, onRemove, selectionCount }) {
9
+ export default function PropertyPanel({ doc, node, control, errors, tables, screens, lockedNames, onChange, onScreenChange, onRemove, selectionCount }) {
9
10
  if (selectionCount > 1) {
10
11
  return (
11
12
  <aside className="xeplr-factory-panel">
@@ -15,13 +16,7 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
15
16
  )
16
17
  }
17
18
  if (!node || !control) {
18
- return (
19
- <aside className="xeplr-factory-panel">
20
- <p className="xeplr-factory-panel-empty">
21
- Select a control to set its label, validation and options — or drag one in from the left.
22
- </p>
23
- </aside>
24
- )
19
+ return <ScreenPanel doc={doc} errors={errors || []} tables={tables} onChange={onScreenChange} />
25
20
  }
26
21
 
27
22
  const nodeErrors = errors || []
@@ -41,21 +36,27 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
41
36
  {group.fields.map((field) => {
42
37
  const errs = errorsFor(field.path)
43
38
  errs.forEach((e) => shown.add(e))
44
- const Editor = EDITORS[field.type] || TextEditor
39
+ // A field name that is already a column cannot change — see setNodeProperty.
40
+ const locked = field.path === 'props.name' && (lockedNames || []).includes(node.props?.name)
41
+ const Editor = locked ? LockedEditor : (EDITORS[field.type] || TextEditor)
45
42
  const id = `xf-prop-${node.id}-${field.path.replace(/\W+/g, '-')}`
46
43
  return (
47
44
  <div key={field.path} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
48
- {field.type !== 'toggle' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
45
+ {field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
49
46
  <Editor
50
47
  id={id}
51
48
  field={field}
52
49
  value={getAtPath(node, field.path)}
53
50
  onChange={(v) => onChange(field.path, v)}
54
51
  tables={tables}
52
+ screens={screens}
55
53
  node={node}
54
+ doc={doc}
56
55
  errors={errs}
57
56
  />
58
- {field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
57
+ {locked
58
+ ? <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>
59
+ : field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
59
60
  {errs.filter((e) => e.field === field.path).map((e, i) => (
60
61
  <div key={i} className="xeplr-factory-prop-error">{e.message}</div>
61
62
  ))}
@@ -79,6 +80,51 @@ export default function PropertyPanel({ node, control, errors, tables, onChange,
79
80
  )
80
81
  }
81
82
 
83
+ /**
84
+ * Nothing selected: the SCREEN's own properties — where records are saved,
85
+ * how wide it is designed, and the font and colours every control inherits.
86
+ */
87
+ function ScreenPanel({ doc, errors, tables, onChange }) {
88
+ const errorFor = (path) => errors.filter((e) => e.path === path || e.path.startsWith(path + '.')).map((e) => e.message)[0]
89
+ const styleFields = SCREEN_STYLE_KEYS.map((k) => ({ path: `style.${k}`, label: k === 'fontSize' ? 'Base font size (px)' : STYLE_KEYS[k].label, ...STYLE_KEYS[k] }))
90
+ const row = (field, Editor, extra) => {
91
+ const id = `xf-screen-${field.path.replace(/\W+/g, '-')}`
92
+ const msg = errorFor(field.path)
93
+ return (
94
+ <div key={field.path} className={`xeplr-factory-prop${msg ? ' has-error' : ''}`}>
95
+ {field.type !== 'toggle' && field.type !== 'toggleValue' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
96
+ <Editor id={id} field={field} value={getAtPath(doc, field.path)} onChange={(v) => onChange(field.path, v)} tables={tables} errors={[]} {...extra} />
97
+ {field.help && <div className="xeplr-factory-prop-help">{field.help}</div>}
98
+ {msg && <div className="xeplr-factory-prop-error">{msg}</div>}
99
+ </div>
100
+ )
101
+ }
102
+ return (
103
+ <aside className="xeplr-factory-panel" aria-label="Screen properties">
104
+ <header className="xeplr-factory-panel-head">
105
+ <span className="xeplr-factory-panel-type">Screen</span>
106
+ </header>
107
+ <p className="xeplr-factory-panel-empty">Select a control to change it, or drag one in from the left.</p>
108
+ <fieldset className="xeplr-factory-group">
109
+ <legend>Data</legend>
110
+ {row({ path: 'source', label: 'Saves to', type: 'table', help: 'The table this screen\'s records are saved in' }, TableEditor)}
111
+ </fieldset>
112
+ <fieldset className="xeplr-factory-group">
113
+ <legend>Size</legend>
114
+ {row({ path: 'width', label: 'Width (px)', type: 'number', help: 'The width it is designed at. Narrower screens scale it down; wider ones never stretch it.' }, NumberEditor)}
115
+ </fieldset>
116
+ <fieldset className="xeplr-factory-group">
117
+ <legend>Text</legend>
118
+ {styleFields.map((f) => row(f, EDITORS[f.type] || TextEditor))}
119
+ </fieldset>
120
+ </aside>
121
+ )
122
+ }
123
+
124
+ function LockedEditor({ id, value }) {
125
+ return <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" value={value ?? ''} readOnly aria-readonly="true" />
126
+ }
127
+
82
128
  function TextEditor({ id, value, onChange }) {
83
129
  return <input id={id} className="xeplr-factory-prop-input" type="text" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
84
130
  }
@@ -106,6 +152,121 @@ function DateEditor({ id, value, onChange }) {
106
152
  return <input id={id} className="xeplr-factory-prop-input" type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
107
153
  }
108
154
 
155
+ /** A font family: the common ones, or anything typed. */
156
+ function FontEditor({ id, value, onChange }) {
157
+ const known = FONT_FAMILIES.some((f) => f.value === value)
158
+ return (
159
+ <select id={id} className="xeplr-factory-prop-input" value={value == null ? '' : known ? value : '__custom'} onChange={(e) => {
160
+ const v = e.target.value
161
+ if (v === '__custom') return
162
+ onChange(v === '' ? undefined : v)
163
+ }} style={{ fontFamily: value || undefined }}>
164
+ <option value="">Same as the screen</option>
165
+ {FONT_FAMILIES.map((f) => <option key={f.label} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>)}
166
+ {!known && value && <option value="__custom">{value}</option>}
167
+ </select>
168
+ )
169
+ }
170
+
171
+ /** A colour: a swatch, and the hex beside it. Clearing it goes back to the default. */
172
+ function ColorEditor({ id, value, onChange }) {
173
+ const hex = typeof value === 'string' && /^#([0-9a-f]{6})$/i.test(value) ? value
174
+ : typeof value === 'string' && /^#([0-9a-f]{3})$/i.test(value) ? '#' + value.slice(1).split('').map((c) => c + c).join('')
175
+ : '#000000'
176
+ return (
177
+ <div className="xeplr-factory-color">
178
+ <input type="color" aria-label="Pick colour" value={hex} onChange={(e) => onChange(e.target.value)} />
179
+ <input id={id} className="xeplr-factory-prop-input xeplr-factory-mono" type="text" placeholder="default" value={value ?? ''} onChange={(e) => onChange(e.target.value.trim())} />
180
+ {value && <button type="button" className="xeplr-factory-icon-button" aria-label="Reset colour" onClick={() => onChange(undefined)}>×</button>}
181
+ </div>
182
+ )
183
+ }
184
+
185
+ /** A style that is either on (e.g. italic) or not set. */
186
+ function ToggleValueEditor({ id, field, value, onChange }) {
187
+ return (
188
+ <label className="xeplr-factory-toggle" htmlFor={id}>
189
+ <input id={id} type="checkbox" checked={value === field.on} onChange={(e) => onChange(e.target.checked ? field.on : undefined)} />
190
+ <span>{field.label}</span>
191
+ </label>
192
+ )
193
+ }
194
+
195
+ /** A table name: picked from the app's tables when it lists them, typed otherwise. */
196
+ function TableEditor({ id, value, onChange, tables }) {
197
+ if (tables && tables.items.length > 0) {
198
+ return (
199
+ <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
200
+ <option value="">{tables.loading ? 'Loading tables…' : '—'}</option>
201
+ {tables.items.map((t) => <option key={String(t.id)} value={String(t.id)}>{t.name}</option>)}
202
+ </select>
203
+ )
204
+ }
205
+ return <input id={id} className="xeplr-factory-prop-input" type="text" placeholder={tables?.loading ? 'Loading tables…' : 'table name'} value={value ?? ''} onChange={(e) => onChange(e.target.value.trim() || undefined)} />
206
+ }
207
+
208
+ /**
209
+ * A list's columns: tick which fields to show, in reading order. The fields
210
+ * are this screen's own — or, for a list screen, those of the screen it edits
211
+ * in. Nothing ticked means all of them.
212
+ */
213
+ function ColumnsEditor({ value, onChange, doc, node, screens }) {
214
+ const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
215
+ const from = doc && inputNodes(doc).length ? doc : editDoc
216
+ const fields = from
217
+ ? inputNodes(from).map((n) => ({ field: n.props.name, label: n.props.label || n.props.name }))
218
+ : (Array.isArray(value) ? value : [])
219
+ const chosen = Array.isArray(value) ? value : null
220
+ const isOn = (f) => (chosen ? chosen.some((c) => c.field === f.field) : true)
221
+ const toggle = (f) => {
222
+ const current = chosen || fields
223
+ const next = isOn(f) ? current.filter((c) => c.field !== f.field) : fields.filter((x) => x.field === f.field || current.some((c) => c.field === x.field))
224
+ // All of them again is the same as "not set" — keep the document clean.
225
+ 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 })))
226
+ }
227
+ if (!fields.length) return <div className="xeplr-factory-prop-help">Add fields to the screen first — the list shows them as columns.</div>
228
+ return (
229
+ <div className="xeplr-factory-checklist">
230
+ {fields.map((f) => (
231
+ <label key={f.field} className="xeplr-factory-toggle">
232
+ <input type="checkbox" checked={isOn(f)} onChange={() => toggle(f)} />
233
+ <span>{f.label}</span>
234
+ </label>
235
+ ))}
236
+ </div>
237
+ )
238
+ }
239
+
240
+ /** Another screen, by id: picked from the ones the app lists, typed otherwise. */
241
+ function ScreenEditor({ id, value, onChange, screens, doc }) {
242
+ const others = (screens || []).filter((s) => !doc || s.id !== doc.id)
243
+ if (others.length) {
244
+ return (
245
+ <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
246
+ <option value="">— open rows in this screen's fields</option>
247
+ {others.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
248
+ </select>
249
+ )
250
+ }
251
+ 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)} />
252
+ }
253
+
254
+ const ACTION_LABELS = { new: 'New — add a record', edit: 'Edit — open a row for editing', delete: 'Delete — remove a row' }
255
+
256
+ function ActionsEditor({ value, onChange }) {
257
+ const on = Array.isArray(value) ? value : LIST_ACTIONS
258
+ return (
259
+ <div className="xeplr-factory-checklist">
260
+ {LIST_ACTIONS.map((a) => (
261
+ <label key={a} className="xeplr-factory-toggle">
262
+ <input type="checkbox" checked={on.includes(a)} onChange={(e) => onChange(e.target.checked ? LIST_ACTIONS.filter((x) => x === a || on.includes(x)) : on.filter((x) => x !== a))} />
263
+ <span>{ACTION_LABELS[a]}</span>
264
+ </label>
265
+ ))}
266
+ </div>
267
+ )
268
+ }
269
+
109
270
  function ToggleEditor({ id, field, value, onChange }) {
110
271
  return (
111
272
  <label className="xeplr-factory-toggle" htmlFor={id}>
@@ -116,8 +277,14 @@ function ToggleEditor({ id, field, value, onChange }) {
116
277
  }
117
278
 
118
279
  function SelectEditor({ id, field, value, onChange }) {
280
+ // Style selects may be unset ("inherit"); numeric option values stay numbers.
281
+ const optional = String(field.path || '').includes('.style.') || String(field.path || '').startsWith('style.')
119
282
  return (
120
- <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
283
+ <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => {
284
+ const hit = field.options.find((o) => String(o.value) === e.target.value)
285
+ onChange(hit ? hit.value : undefined)
286
+ }}>
287
+ {optional && <option value="">Default</option>}
121
288
  {field.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
122
289
  </select>
123
290
  )
@@ -223,6 +390,13 @@ export const EDITORS = {
223
390
  number: NumberEditor,
224
391
  date: DateEditor,
225
392
  toggle: ToggleEditor,
393
+ toggleValue: ToggleValueEditor,
226
394
  select: SelectEditor,
395
+ font: FontEditor,
396
+ color: ColorEditor,
397
+ table: TableEditor,
398
+ screen: ScreenEditor,
399
+ columns: ColumnsEditor,
400
+ actions: ActionsEditor,
227
401
  dataSource: DataSourceEditor
228
402
  }
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ // The popup a list's Edit / New opens its edit screen in.
4
+ //
5
+ // A real dialog: focus moves in, Escape and the backdrop close it, and focus
6
+ // returns to whatever opened it. There is nothing to confirm on close — the
7
+ // screen inside has already saved itself.
8
+
9
+ export default function ScreenModal({ title, width, onClose, children }) {
10
+ const dialogRef = useRef(null)
11
+
12
+ useEffect(() => {
13
+ const opener = typeof document !== 'undefined' ? document.activeElement : null
14
+ dialogRef.current?.focus()
15
+ function onKey(e) { if (e.key === 'Escape') onClose() }
16
+ window.addEventListener('keydown', onKey)
17
+ return () => {
18
+ window.removeEventListener('keydown', onKey)
19
+ if (opener && opener.focus) opener.focus()
20
+ }
21
+ }, [onClose])
22
+
23
+ return (
24
+ <div className="xeplr-factory-modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}>
25
+ <div
26
+ ref={dialogRef}
27
+ className="xeplr-factory-modal"
28
+ role="dialog"
29
+ aria-modal="true"
30
+ aria-label={title}
31
+ tabIndex={-1}
32
+ style={{ width: width ? `min(${width + 48}px, 96vw)` : undefined }}
33
+ >
34
+ <header className="xeplr-factory-modal-head">
35
+ <span className="xeplr-factory-modal-title">{title}</span>
36
+ <button type="button" className="xeplr-factory-modal-close" onClick={onClose} aria-label="Close">×</button>
37
+ </header>
38
+ <div className="xeplr-factory-modal-body">{children}</div>
39
+ </div>
40
+ </div>
41
+ )
42
+ }
@@ -1,15 +1,27 @@
1
1
  import ControlView from './ControlView.jsx'
2
- import { contentBottom, MARGIN } from '../document.js'
2
+ import { contentBottom, inputNodes, MARGIN } from '../document.js'
3
+ import { screenStyle } from './styles.js'
3
4
 
4
5
  // A saved screen, drawn. Presentation only — everything comes from
5
6
  // useFactoryScreen.
6
7
  //
7
8
  // NO MEASURING. Positions are CSS: x and w as percentages of the width, y and h
8
9
  // in container-query units (cqw = 1% of the screen's width) times the aspect.
9
- // So the screen keeps exactly its proportions at any width, renders on the
10
- // server, and never flashes at the wrong size while a ResizeObserver catches up.
10
+ // The screen is never wider than the width it was designed at, so what was
11
+ // designed at 800px is shown at 800px, and only a narrower screen scales it.
12
+ //
13
+ // NO SUBMIT. The status line says what the background save is doing.
14
+
15
+ const STATUS = {
16
+ idle: '',
17
+ pending: 'Unsaved changes…',
18
+ saving: 'Saving…',
19
+ saved: 'All changes saved',
20
+ incomplete: 'Fill in the required fields to save',
21
+ invalid: 'Fix the highlighted fields to save'
22
+ }
11
23
 
12
- export default function ScreenSample({ ctrl, className, style }) {
24
+ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
13
25
  const { document: doc } = ctrl
14
26
 
15
27
  if (ctrl.documentErrors.length) {
@@ -25,13 +37,27 @@ export default function ScreenSample({ ctrl, className, style }) {
25
37
 
26
38
  const aspect = doc.aspect
27
39
  const pageHeight = (contentBottom(doc) + MARGIN) * aspect * 100
40
+ const fieldNodes = inputNodes(doc)
41
+ const statusText = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
42
+ // A screen with no fields of its own (a list screen) has nothing to save.
43
+ const hasFields = fieldNodes.length > 0
28
44
 
29
45
  return (
30
- <div className={'xeplr-factory-screen' + (className ? ' ' + className : '')} style={style}>
46
+ <div
47
+ className={'xeplr-factory-screen' + (className ? ' ' + className : '')}
48
+ style={{ maxWidth: doc.width, ...screenStyle(doc), ...style }}
49
+ >
50
+ {hasFields && (
51
+ <div className={`xeplr-factory-status xeplr-factory-status--${ctrl.status}`} role="status" aria-live="polite">
52
+ <span>{ctrl.recordId !== null && ctrl.recordId !== undefined ? 'Editing record' : 'New record'}</span>
53
+ <span>{statusText}</span>
54
+ </div>
55
+ )}
31
56
  <form
32
57
  className="xeplr-factory-page"
33
58
  style={{ height: `${pageHeight}cqw` }}
34
- onSubmit={ctrl.submit}
59
+ // Enter in a field saves now rather than posting a page.
60
+ onSubmit={(e) => { e.preventDefault(); ctrl.saveNow() }}
35
61
  noValidate
36
62
  aria-label={doc.name}
37
63
  >
@@ -53,13 +79,27 @@ export default function ScreenSample({ ctrl, className, style }) {
53
79
  error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
54
80
  options={node.type === 'dropdown' ? ctrl.optionsFor(node) : undefined}
55
81
  onChange={(raw) => ctrl.setValue(node, raw)}
56
- onAction={(action) => { if (action === 'reset') ctrl.reset() }}
57
- disabled={ctrl.submitting}
82
+ onBlur={() => ctrl.touch(node)}
83
+ list={node.type === 'list' ? {
84
+ doc,
85
+ list: ctrl.listFor(node),
86
+ fieldNodes: ctrl.fieldsFor(node),
87
+ optionsFor: ctrl.optionsFor,
88
+ currentId: ctrl.recordId,
89
+ recordKey: ctrl.recordKey,
90
+ canDelete: ctrl.canDelete,
91
+ // A list with an edit screen opens it in a popup; one on a form
92
+ // opens the row in the form's own fields.
93
+ onEdit: (rec) => (node.props.editScreen ? ctrl.openEditor(node, rec) : ctrl.openRecord(rec)),
94
+ // eslint-disable-next-line no-alert
95
+ onDelete: (rec) => ctrl.deleteRecord(node, rec).catch((err) => window.alert(err.message || 'Could not delete')),
96
+ onNew: () => (node.props.editScreen ? ctrl.openEditor(node, null) : ctrl.newRecord())
97
+ } : undefined}
58
98
  />
59
99
  </div>
60
100
  ))}
61
101
  </form>
62
- {ctrl.submitError && <div className="xeplr-factory-submit-error" role="alert">{ctrl.submitError}</div>}
102
+ {ctrl.popup && renderPopup && renderPopup(ctrl.popup, ctrl)}
63
103
  </div>
64
104
  )
65
105
  }
@@ -2,14 +2,15 @@
2
2
  Colours come from the xeplr theme variables, each with a light fallback, so
3
3
  a host that defines none still gets a finished look.
4
4
 
5
- ONE SIZE UNIT, --xf-u: one design pixel on an 800px-wide screen. It is in
6
- container-query units, so text and controls scale with the screen exactly as
7
- their positions do — the whole screen keeps its proportions at any width. */
5
+ ONE SIZE UNIT, --xf-u: one pixel at the screen's design width (--xf-width,
6
+ set from the document). At that width or wider it IS a pixel — text is
7
+ exactly the size chosen, and a screen never stretches. On a narrower screen
8
+ it shrinks, so the layout keeps its proportions. */
8
9
 
9
10
  .xeplr-factory-screen,
10
11
  .xeplr-factory-canvas {
11
12
  container-type: inline-size;
12
- --xf-u: calc(100cqw / 800);
13
+ --xf-u: min(1px, calc(100cqw / var(--xf-width, 800)));
13
14
  --xf-accent: var(--xeplr-accent, #2563eb);
14
15
  --xf-ink: var(--xeplr-text, #1a1a19);
15
16
  --xf-muted: var(--xeplr-muted, #6b6a63);
@@ -20,6 +21,12 @@
20
21
 
21
22
  /* ── the screen ─────────────────────────────────────────────────────────── */
22
23
  .xeplr-factory-screen { position: relative; width: 100%; color: var(--xf-ink); }
24
+ .xeplr-factory-status {
25
+ display: flex; justify-content: space-between; gap: 12px; min-height: 18px;
26
+ padding: 4px 2px 8px; font-size: 12px; color: var(--xf-muted);
27
+ }
28
+ .xeplr-factory-status--saved { color: var(--xeplr-status-good, #1d7a4c); }
29
+ .xeplr-factory-status--invalid, .xeplr-factory-status--error { color: var(--xf-danger); }
23
30
  .xeplr-factory-page { position: relative; width: 100%; margin: 0; }
24
31
  .xeplr-factory-node { position: absolute; box-sizing: border-box; }
25
32
 
@@ -36,10 +43,10 @@
36
43
  position: relative;
37
44
  display: flex; flex-direction: column; gap: calc(4 * var(--xf-u));
38
45
  width: 100%; height: 100%; box-sizing: border-box;
39
- font-size: max(11px, calc(14 * var(--xf-u)));
46
+ font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
40
47
  }
41
48
  .xeplr-factory-field--inline { justify-content: center; }
42
- .xeplr-factory-label { font-weight: 600; font-size: max(11px, calc(13 * var(--xf-u))); color: var(--xf-ink); line-height: 1.2; }
49
+ .xeplr-factory-label { font-weight: 600; font-size: max(10px, calc((var(--xf-font, 14) - 1) * var(--xf-u))); color: var(--xf-ink); line-height: 1.2; }
43
50
  .xeplr-factory-required { color: var(--xf-danger); }
44
51
  .xeplr-factory-input {
45
52
  flex: 1; min-height: 0; width: 100%; box-sizing: border-box;
@@ -61,20 +68,30 @@
61
68
  .xeplr-factory-check { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; font-weight: 500; }
62
69
  .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); }
63
70
 
64
- .xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; }
65
- .xeplr-factory-text--heading { font-size: max(16px, calc(26 * var(--xf-u))); font-weight: 700; }
66
- .xeplr-factory-text--subheading { font-size: max(13px, calc(18 * var(--xf-u))); font-weight: 600; }
67
- .xeplr-factory-text--text { font-size: max(11px, calc(14 * var(--xf-u))); color: var(--xf-muted); }
71
+ /* A label's size and weight are inline — its preset, overridden by its style. */
72
+ .xeplr-factory-text { margin: 0; color: var(--xf-ink); height: 100%; overflow: hidden; line-height: 1.25; box-sizing: border-box; }
73
+ .xeplr-factory-text--text { color: var(--xf-muted); }
68
74
 
69
- .xeplr-factory-button {
70
- width: 100%; height: 100%; box-sizing: border-box; cursor: pointer;
71
- padding: 0 calc(8 * var(--xf-u)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
72
- font: inherit; font-weight: 600; font-size: max(12px, calc(14 * var(--xf-u)));
73
- border-radius: calc(6 * var(--xf-u)); border: 1px solid var(--xf-accent);
75
+ /* ── list ───────────────────────────────────────────────────────────────── */
76
+ .xeplr-factory-list {
77
+ display: flex; flex-direction: column; width: 100%; height: 100%; box-sizing: border-box; overflow: auto;
78
+ font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
79
+ border: 1px solid var(--xf-border); border-radius: calc(8 * var(--xf-u)); background: var(--xf-surface);
80
+ }
81
+ .xeplr-factory-list-head { display: flex; align-items: center; gap: 10px; padding: calc(8 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); }
82
+ .xeplr-factory-list-title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83
+ .xeplr-factory-list-new {
84
+ font: inherit; font-weight: 600; font-size: .9em; padding: 3px 10px; border-radius: 5px; cursor: pointer;
85
+ color: var(--xf-accent); background: transparent; border: 1px solid var(--xf-accent);
74
86
  }
75
- .xeplr-factory-button--submit { background: var(--xf-accent); color: #fff; }
76
- .xeplr-factory-button--reset { background: var(--xf-surface); color: var(--xf-accent); }
77
- .xeplr-factory-button:disabled { opacity: .6; cursor: default; }
87
+ .xeplr-factory-list-empty { padding: 16px; color: var(--xf-muted); text-align: center; }
88
+ .xeplr-factory-error-inline { color: var(--xf-danger); font-size: .9em; }
89
+ .xeplr-factory-list-mock { width: 100%; border-collapse: collapse; }
90
+ .xeplr-factory-list-mock th { text-align: left; font-weight: 600; font-size: .9em; padding: calc(6 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); color: var(--xf-muted); }
91
+ .xeplr-factory-list-mock td { padding: calc(8 * var(--xf-u)) calc(10 * var(--xf-u)); border-bottom: 1px solid var(--xf-border); }
92
+ .xeplr-factory-list-bar { display: block; height: calc(8 * var(--xf-u)); width: 70%; border-radius: 4px; background: var(--xeplr-border-secondary, #ecebe4); }
93
+ .xeplr-factory-list .xeplr-table-row.xeplr-factory-row-open td,
94
+ .xeplr-factory-list tr.xeplr-factory-row-open td { background: color-mix(in srgb, var(--xf-accent) 10%, transparent); }
78
95
 
79
96
  /* ── the builder ────────────────────────────────────────────────────────── */
80
97
  .xeplr-factory-builder {
@@ -107,6 +124,8 @@
107
124
  .xeplr-factory-save-error { padding: 6px 12px; color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); font-size: 12px; }
108
125
 
109
126
  .xeplr-factory-body { flex: 1; min-height: 0; display: flex; }
127
+ .xeplr-factory-bar-status--saved { color: var(--xeplr-status-good, #1d7a4c); }
128
+ .xeplr-factory-bar-status--invalid, .xeplr-factory-bar-status--error { color: var(--xf-danger); }
110
129
  .xeplr-factory-preview { flex: 1; min-height: 0; overflow: auto; padding: 16px; background: var(--xeplr-plane, #f7f7f5); }
111
130
  .xeplr-factory-preview > .xeplr-factory-screen { max-width: 960px; margin: 0 auto; background: var(--xeplr-surface, #fff); border: 1px solid var(--xf-border); border-radius: 8px; }
112
131
 
@@ -123,11 +142,13 @@
123
142
  .xeplr-factory-palette-icon { width: 14px; height: 10px; border: 1.5px solid var(--xf-muted); border-radius: 2px; flex: 0 0 auto; }
124
143
  .xeplr-factory-palette-icon--checkbox { width: 10px; }
125
144
  .xeplr-factory-palette-icon--label { border: none; border-top: 2px solid var(--xf-muted); border-bottom: 2px solid var(--xf-muted); height: 6px; }
126
- .xeplr-factory-palette-icon--button { background: var(--xf-muted); }
145
+ .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; }
127
146
  .xeplr-factory-palette-icon--textarea { height: 14px; }
128
147
 
129
148
  /* canvas */
130
- .xeplr-factory-stage { flex: 1; min-width: 0; min-height: 0; display: flex; background: var(--xeplr-plane, #f7f7f5); }
149
+ .xeplr-factory-stage { flex: 1; min-width: 0; min-height: 0; display: flex; justify-content: center; padding: 12px; box-sizing: border-box; background: var(--xeplr-plane, #f7f7f5); }
150
+ /* Exactly the screen's design width, never wider: what you design is the size it is shown at. */
151
+ .xeplr-factory-frame { flex: 1; min-width: 0; height: 100%; display: flex; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
131
152
  .xeplr-factory-canvas {
132
153
  flex: 1; height: 100%;
133
154
  background:
@@ -175,3 +196,30 @@
175
196
  .xeplr-factory-option-error { grid-column: 1 / -1; margin-top: 0; }
176
197
  .xeplr-factory-icon-button { border: none; background: none; cursor: pointer; font-size: 16px; color: var(--xf-muted); padding: 0; }
177
198
  .xeplr-factory-add { font: inherit; font-size: 12px; background: none; border: 1px dashed var(--xf-border); border-radius: 5px; padding: 5px; width: 100%; cursor: pointer; color: var(--xf-accent); }
199
+
200
+ .xeplr-factory-color { display: grid; grid-template-columns: 32px 1fr 20px; gap: 4px; align-items: center; }
201
+ .xeplr-factory-color input[type="color"] { width: 32px; height: 28px; padding: 0; border: 1px solid var(--xf-border); border-radius: 5px; background: none; cursor: pointer; }
202
+ .xeplr-factory-checklist { display: flex; flex-direction: column; gap: 5px; }
203
+ .xeplr-factory-checklist .xeplr-factory-toggle { font-weight: 500; }
204
+
205
+ /* ── popup ──────────────────────────────────────────────────────────────── */
206
+ .xeplr-factory-modal-backdrop {
207
+ position: fixed; inset: 0; z-index: 10000; display: flex; align-items: flex-start; justify-content: center;
208
+ padding: 4vh 2vw; box-sizing: border-box; overflow: auto;
209
+ background: rgba(20, 20, 18, .45);
210
+ }
211
+ .xeplr-factory-modal {
212
+ max-width: 96vw; background: var(--xeplr-surface, #fff); color: var(--xeplr-text, #1a1a19);
213
+ border-radius: 10px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); outline: none;
214
+ }
215
+ .xeplr-factory-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--xeplr-border, #e2e1d9); }
216
+ .xeplr-factory-modal-title { font-weight: 700; font-size: 15px; }
217
+ .xeplr-factory-modal-close { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--xeplr-muted, #6b6a63); padding: 2px 6px; }
218
+ .xeplr-factory-modal-body { padding: 8px 24px 16px; }
219
+
220
+ /* ── publish result ─────────────────────────────────────────────────────── */
221
+ .xeplr-factory-publish { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; font-size: 12.5px; border-bottom: 1px solid var(--xf-border); }
222
+ .xeplr-factory-publish > span { flex: 1; }
223
+ .xeplr-factory-publish.is-ok { color: var(--xeplr-status-good, #1d7a4c); background: color-mix(in srgb, var(--xeplr-status-good, #1d7a4c) 7%, transparent); }
224
+ .xeplr-factory-publish.is-error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 7%, transparent); flex-wrap: wrap; }
225
+ .xeplr-factory-publish-detail { flex-basis: 100%; order: 3; margin: 4px 0 0; padding: 8px; max-height: 220px; overflow: auto; font-size: 11.5px; color: var(--xeplr-text, #1a1a19); background: var(--xeplr-surface, #fff); border: 1px solid var(--xf-border); border-radius: 6px; white-space: pre; }
@@ -2,6 +2,8 @@ import './factory.css'
2
2
 
3
3
  export { default as BuilderSample } from './BuilderSample.jsx'
4
4
  export { default as ScreenSample } from './ScreenSample.jsx'
5
+ export { default as ListView } from './ListView.jsx'
6
+ export { default as ScreenModal } from './ScreenModal.jsx'
5
7
  export { default as ControlView, VIEWS } from './ControlView.jsx'
6
8
  export { default as Palette, DRAG_TYPE } from './Palette.jsx'
7
9
  export { default as PropertyPanel, EDITORS } from './PropertyPanel.jsx'