@xeplr/ui-factory 1.0.1 → 1.0.3

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,13 +1,17 @@
1
+ import { LABEL_PRESETS } from '../controls.js'
2
+ import ListView from './ListView.jsx'
3
+ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
4
+
1
5
  // How each control LOOKS — the one renderer, used both on the builder's
2
6
  // canvas (mode 'design': inert, so a press drags it) and on a live screen
3
7
  // (mode 'live'). Sharing it is what makes the builder honest: what you place is
4
- // what the form shows.
8
+ // what the form shows, fonts and colours included.
5
9
  //
6
- // Presentation only. Values, options and errors arrive as props.
10
+ // Presentation only. Values, options, errors and list data arrive as props.
7
11
 
8
12
  const ids = (node) => `xeplr-factory-${node.id}`
9
13
 
10
- export default function ControlView({ node, mode = 'live', value, error, options, onChange, onAction, disabled }) {
14
+ export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list }) {
11
15
  const View = VIEWS[node.type]
12
16
  if (!View) {
13
17
  return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
@@ -22,17 +26,18 @@ export default function ControlView({ node, mode = 'live', value, error, options
22
26
  error={design ? null : error}
23
27
  options={options || { loading: false, items: [], error: null }}
24
28
  onChange={onChange || (() => {})}
25
- onAction={onAction || (() => {})}
29
+ onBlur={onBlur || (() => {})}
26
30
  disabled={design || disabled}
31
+ list={list}
27
32
  />
28
33
  )
29
34
  }
30
35
 
31
36
  function Field({ node, p, error, children, inline }) {
32
37
  return (
33
- <div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`}>
38
+ <div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`} style={fieldStyle(p.style)}>
34
39
  {!inline && (
35
- <label className="xeplr-factory-label" htmlFor={ids(node)}>
40
+ <label className="xeplr-factory-label" htmlFor={ids(node)} style={labelStyle(p.style)}>
36
41
  {p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
37
42
  </label>
38
43
  )}
@@ -42,19 +47,21 @@ function Field({ node, p, error, children, inline }) {
42
47
  )
43
48
  }
44
49
 
45
- function inputProps(node, p, error, disabled) {
50
+ function inputProps(node, p, error, disabled, onBlur) {
46
51
  return {
47
52
  id: ids(node),
48
53
  name: p.name,
49
54
  disabled,
50
- required: undefined, // validation is ours, with our messages — not the browser's bubbles
51
55
  'aria-invalid': error ? true : undefined,
52
56
  'aria-describedby': error ? `${ids(node)}-error` : undefined,
53
- tabIndex: disabled ? -1 : undefined
57
+ 'aria-required': p.required ? true : undefined,
58
+ tabIndex: disabled ? -1 : undefined,
59
+ onBlur,
60
+ style: boxStyle(p.style)
54
61
  }
55
62
  }
56
63
 
57
- function TextView({ node, p, value, error, onChange, disabled }) {
64
+ function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
58
65
  return (
59
66
  <Field node={node} p={p} error={error}>
60
67
  <input
@@ -63,13 +70,13 @@ function TextView({ node, p, value, error, onChange, disabled }) {
63
70
  placeholder={p.placeholder || ''}
64
71
  value={value ?? ''}
65
72
  onChange={(e) => onChange(e.target.value)}
66
- {...inputProps(node, p, error, disabled)}
73
+ {...inputProps(node, p, error, disabled, onBlur)}
67
74
  />
68
75
  </Field>
69
76
  )
70
77
  }
71
78
 
72
- function TextareaView({ node, p, value, error, onChange, disabled }) {
79
+ function TextareaView({ node, p, value, error, onChange, onBlur, disabled }) {
73
80
  return (
74
81
  <Field node={node} p={p} error={error}>
75
82
  <textarea
@@ -77,13 +84,13 @@ function TextareaView({ node, p, value, error, onChange, disabled }) {
77
84
  placeholder={p.placeholder || ''}
78
85
  value={value ?? ''}
79
86
  onChange={(e) => onChange(e.target.value)}
80
- {...inputProps(node, p, error, disabled)}
87
+ {...inputProps(node, p, error, disabled, onBlur)}
81
88
  />
82
89
  </Field>
83
90
  )
84
91
  }
85
92
 
86
- function NumberView({ node, p, value, error, onChange, disabled }) {
93
+ function NumberView({ node, p, value, error, onChange, onBlur, disabled }) {
87
94
  const v = p.validation || {}
88
95
  return (
89
96
  <Field node={node} p={p} error={error}>
@@ -95,13 +102,13 @@ function NumberView({ node, p, value, error, onChange, disabled }) {
95
102
  placeholder={p.placeholder || ''}
96
103
  value={value ?? ''}
97
104
  onChange={(e) => onChange(e.target.value)}
98
- {...inputProps(node, p, error, disabled)}
105
+ {...inputProps(node, p, error, disabled, onBlur)}
99
106
  />
100
107
  </Field>
101
108
  )
102
109
  }
103
110
 
104
- function DateView({ node, p, value, error, onChange, disabled }) {
111
+ function DateView({ node, p, value, error, onChange, onBlur, disabled }) {
105
112
  const v = p.validation || {}
106
113
  return (
107
114
  <Field node={node} p={p} error={error}>
@@ -112,21 +119,23 @@ function DateView({ node, p, value, error, onChange, disabled }) {
112
119
  max={v.max}
113
120
  value={value ?? ''}
114
121
  onChange={(e) => onChange(e.target.value)}
115
- {...inputProps(node, p, error, disabled)}
122
+ {...inputProps(node, p, error, disabled, onBlur)}
116
123
  />
117
124
  </Field>
118
125
  )
119
126
  }
120
127
 
121
- function CheckboxView({ node, p, value, error, onChange, disabled }) {
128
+ function CheckboxView({ node, p, value, error, onChange, onBlur, disabled }) {
129
+ const text = boxStyle(p.style)
122
130
  return (
123
131
  <Field node={node} p={p} error={error} inline>
124
- <label className="xeplr-factory-check" htmlFor={ids(node)}>
132
+ <label className="xeplr-factory-check" htmlFor={ids(node)} style={{ fontSize: text.fontSize, fontWeight: text.fontWeight, fontStyle: text.fontStyle, color: text.color }}>
125
133
  <input
126
134
  type="checkbox"
127
135
  checked={Boolean(value ?? p.default)}
128
136
  onChange={(e) => onChange(e.target.checked)}
129
- {...inputProps(node, p, error, disabled)}
137
+ {...inputProps(node, p, error, disabled, onBlur)}
138
+ style={undefined}
130
139
  />
131
140
  <span>{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}</span>
132
141
  </label>
@@ -134,7 +143,7 @@ function CheckboxView({ node, p, value, error, onChange, disabled }) {
134
143
  )
135
144
  }
136
145
 
137
- function DropdownView({ node, p, design, value, error, options, onChange, disabled }) {
146
+ function DropdownView({ node, p, design, value, error, options, onChange, onBlur, disabled }) {
138
147
  const data = p.data || {}
139
148
  const items = data.source === 'static' ? (data.options || []) : options.items
140
149
  return (
@@ -143,7 +152,7 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
143
152
  className="xeplr-factory-input xeplr-factory-select"
144
153
  value={value === undefined || value === null ? '' : String(value)}
145
154
  onChange={(e) => onChange(e.target.value)}
146
- {...inputProps(node, p, error, disabled || (!design && options.loading))}
155
+ {...inputProps(node, p, error, disabled || (!design && options.loading), onBlur)}
147
156
  >
148
157
  <option value="">{!design && options.loading ? 'Loading…' : (p.placeholder || 'Select…')}</option>
149
158
  {!design && items.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
@@ -161,25 +170,23 @@ function DropdownView({ node, p, design, value, error, options, onChange, disabl
161
170
 
162
171
  function LabelView({ p }) {
163
172
  const variant = p.variant || 'text'
173
+ const preset = LABEL_PRESETS[variant] || LABEL_PRESETS.text
164
174
  const Tag = variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'
165
- return <Tag className={`xeplr-factory-text xeplr-factory-text--${variant}`}>{p.text}</Tag>
166
- }
167
-
168
- function ButtonView({ p, design, onAction, disabled }) {
169
- const action = p.action || 'submit'
175
+ const s = { fontSize: preset.fontSize, fontWeight: preset.fontWeight, ...(p.style || {}) }
170
176
  return (
171
- <button
172
- className={`xeplr-factory-button xeplr-factory-button--${action}`}
173
- type={design ? 'button' : action === 'reset' ? 'button' : 'submit'}
174
- disabled={disabled}
175
- tabIndex={design ? -1 : undefined}
176
- onClick={action === 'reset' && !design ? () => onAction('reset') : undefined}
177
+ <Tag
178
+ className={`xeplr-factory-text xeplr-factory-text--${variant}`}
179
+ style={{ ...fieldStyle(s), ...boxStyle(s) }}
177
180
  >
178
- {p.label}
179
- </button>
181
+ {p.text}
182
+ </Tag>
180
183
  )
181
184
  }
182
185
 
186
+ function ListControl({ node, design, list }) {
187
+ return <ListView node={node} design={design} {...(list || {})} />
188
+ }
189
+
183
190
  export const VIEWS = {
184
191
  text: TextView,
185
192
  textarea: TextareaView,
@@ -188,5 +195,5 @@ export const VIEWS = {
188
195
  checkbox: CheckboxView,
189
196
  dropdown: DropdownView,
190
197
  label: LabelView,
191
- button: ButtonView
198
+ list: ListControl
192
199
  }
@@ -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
+ }