@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,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,34 @@
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; }
226
+ .xeplr-factory-publish.is-confirm { align-items: center; color: var(--xeplr-text, #1a1a19); background: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 10%, transparent); border-bottom-color: color-mix(in srgb, var(--xeplr-status-warn, #a86a00) 40%, transparent); }
227
+ .xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
228
+ .xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
229
+ .xeplr-factory-confirm-actions { display: flex; gap: 8px; flex-shrink: 0; }
@@ -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'
@@ -0,0 +1,59 @@
1
+ // A control's `props.style` → CSS.
2
+ //
3
+ // Sizes are PIXELS at the screen's design width. `--xf-u` is one such pixel: a
4
+ // real pixel at the design width or wider, less on a narrower screen (see
5
+ // factory.css). So text stays exactly the size somebody chose, and only ever
6
+ // shrinks to keep the layout's proportions.
7
+
8
+ const u = (n) => `calc(${n} * var(--xf-u))`
9
+
10
+ /** Wrapper-level: the font the whole control uses. */
11
+ export function fieldStyle(style) {
12
+ const s = style || {}
13
+ return clean({ fontFamily: s.fontFamily })
14
+ }
15
+
16
+ /** The label above an input. */
17
+ export function labelStyle(style) {
18
+ const s = style || {}
19
+ return clean({
20
+ fontSize: s.labelFontSize != null ? u(s.labelFontSize) : undefined,
21
+ fontWeight: s.labelFontWeight,
22
+ color: s.labelColor
23
+ })
24
+ }
25
+
26
+ /** The input box itself, or a label control's text block. */
27
+ export function boxStyle(style) {
28
+ const s = style || {}
29
+ return clean({
30
+ fontSize: s.fontSize != null ? u(s.fontSize) : undefined,
31
+ fontWeight: s.fontWeight,
32
+ fontStyle: s.fontStyle,
33
+ textAlign: s.textAlign,
34
+ color: s.color,
35
+ background: s.background,
36
+ borderColor: s.borderColor,
37
+ borderWidth: s.borderWidth != null ? `${s.borderWidth}px` : undefined,
38
+ borderStyle: s.borderWidth != null ? 'solid' : undefined,
39
+ borderRadius: s.borderRadius != null ? u(s.borderRadius) : undefined
40
+ })
41
+ }
42
+
43
+ /** The screen's defaults, on its container: what every control inherits. */
44
+ export function screenStyle(doc) {
45
+ const s = doc.style || {}
46
+ return clean({
47
+ '--xf-width': doc.width,
48
+ '--xf-font': s.fontSize,
49
+ fontFamily: s.fontFamily,
50
+ color: s.color,
51
+ background: s.background
52
+ })
53
+ }
54
+
55
+ function clean(obj) {
56
+ const out = {}
57
+ Object.keys(obj).forEach((k) => { if (obj[k] !== undefined && obj[k] !== null && obj[k] !== '') out[k] = obj[k] })
58
+ return out
59
+ }
package/src/document.js CHANGED
@@ -5,7 +5,10 @@
5
5
  // {
6
6
  // kind: 'xeplr-screen', version: 1,
7
7
  // id: 'new_employee', name: 'New employee',
8
+ // source?: 'employees', ← the table records are saved to (the app's)
8
9
  // units: 'fraction', aspect: 1,
10
+ // width: 800, ← design width in px: styles are px at this width
11
+ // style?: { fontFamily, fontSize, color, background }, ← screen-wide defaults
9
12
  // nodes: [ { id, type, x, y, w, h, z?, groupId?, props } ]
10
13
  // }
11
14
  //
@@ -22,29 +25,44 @@ import { getAtPath, setAtPath } from './propertyPath.js'
22
25
  export const DOCUMENT_KIND = 'xeplr-screen'
23
26
  export const DOCUMENT_VERSION = 1
24
27
  export const DEFAULT_ASPECT = 1
28
+ /** Px. Styles are sizes at this width; narrower shrinks them, wider never grows them. */
29
+ export const DEFAULT_WIDTH = 800
25
30
 
26
31
  /** Space kept around and between controls, as fractions. */
27
32
  export const MARGIN = 0.04
28
33
  export const GAP = 0.025
29
34
 
30
35
  /** A new, empty screen. */
31
- export function createScreen({ name, id, aspect } = {}) {
36
+ export function createScreen({ name, id, aspect, width, source, style } = {}) {
32
37
  const title = String(name || 'Untitled screen').trim() || 'Untitled screen'
33
- return {
38
+ const doc = {
34
39
  kind: DOCUMENT_KIND,
35
40
  version: DOCUMENT_VERSION,
36
41
  id: id || slugify(title, '_') || 'screen',
37
42
  name: title,
38
43
  units: 'fraction',
39
44
  aspect: aspect > 0 ? aspect : DEFAULT_ASPECT,
45
+ width: width > 0 ? width : DEFAULT_WIDTH,
40
46
  nodes: []
41
47
  }
48
+ if (source) doc.source = source
49
+ if (style && Object.keys(style).length) doc.style = style
50
+ return doc
42
51
  }
43
52
 
44
53
  export function renameScreen(doc, name) {
45
54
  return { ...doc, name: String(name == null ? '' : name) }
46
55
  }
47
56
 
57
+ /**
58
+ * Sets a SCREEN property by path — `source`, `width`, `style.fontSize`. A blank
59
+ * value removes the key, like a control's properties.
60
+ */
61
+ export function setScreenProperty(doc, path, value) {
62
+ if (path === 'nodes' || path.startsWith('nodes.')) throw new Error('setScreenProperty cannot change nodes')
63
+ return setAtPath(doc, path, value)
64
+ }
65
+
48
66
  /**
49
67
  * Adds a control of `type`. Placed at `at` ({ x, y } — the drop point, as the
50
68
  * control's top-left) or, without one, below everything already on the screen.
@@ -87,10 +105,19 @@ export function moveNode(doc, id, patch) {
87
105
  * name is still the one made for it: a dropdown dropped as "Dropdown" and
88
106
  * relabelled "Location" should save as `location`, not `dropdown`. A name
89
107
  * someone typed is theirs, and is left alone.
108
+ *
109
+ * A LOCKED name — one that is already a column of the table — never changes:
110
+ * not by typing, and not by following the label. Renaming it would start a new
111
+ * empty column and strand the saved data in the old one.
112
+ *
113
+ * @param options.lockedNames field names that are columns already
90
114
  */
91
- export function setNodeProperty(doc, id, path, value, controls = CONTROLS) {
115
+ export function setNodeProperty(doc, id, path, value, controls = CONTROLS, options = {}) {
92
116
  const node = doc.nodes.find((n) => n.id === id)
93
- const followLabel = path === 'props.label' && node && controls[node.type]?.input && isAutoName(node, controls)
117
+ const locked = options.lockedNames ? new Set(options.lockedNames) : null
118
+ const isLocked = Boolean(node && locked && locked.has(node.props?.name))
119
+ if (path === 'props.name' && isLocked) return doc
120
+ const followLabel = path === 'props.label' && node && !isLocked && controls[node.type]?.input && isAutoName(node, controls)
94
121
  let next = mapNode(doc, id, (n) => {
95
122
  const updated = setAtPath(n, path, value)
96
123
  // setAtPath prunes an emptied object; `props` itself must always exist.
package/src/generate.js CHANGED
@@ -24,15 +24,15 @@
24
24
  // there it is edited like any other.
25
25
 
26
26
  import { CONTROLS } from './controls.js'
27
- import { createScreen, addControl, MARGIN, GAP, slugify } from './document.js'
27
+ import { createScreen, addControl, MARGIN, GAP, slugify, camelName } from './document.js'
28
28
  import { assertValidDocument } from './validateDocument.js'
29
29
 
30
30
  /** Heights by control, as page fractions (a page is as tall as it is wide). */
31
31
  export const SPEC_HEIGHTS = {
32
32
  text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
33
33
  textarea: 0.18,
34
- checkbox: 0.06,
35
- button: 0.07,
34
+ checkbox: 0.05,
35
+ list: 0.42,
36
36
  label: { heading: 0.07, subheading: 0.055, text: 0.045 }
37
37
  }
38
38
 
@@ -42,8 +42,9 @@ export const SPEC_HEIGHTS = {
42
42
  */
43
43
  export const ROW_GAP = 0.04
44
44
 
45
- const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant']
46
- const SPEC_KEYS = ['name', 'id', 'columns', 'heading', 'fields', 'submit', 'reset', 'aspect']
45
+ const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'width', 'text', 'variant', 'style']
46
+ const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
47
+ const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
47
48
 
48
49
  /**
49
50
  * @param spec
@@ -55,8 +56,13 @@ const SPEC_KEYS = ['name', 'id', 'columns', 'heading', 'fields', 'submit', 'rese
55
56
  * default?, validation?, width?: 'half'|'full' }
56
57
  * dropdown: options: ['Full time', …] or [{ id, name }] —or— table: 'departments'
57
58
  * section heading: { type: 'label', text, variant?: 'subheading' }
58
- * submit? button label (default 'Save'); false for no submit button
59
- * reset? reset button label; omitted/false for none
59
+ * source? the table records are saved to, e.g. 'employees'
60
+ * width? design width in px (default 800) — style sizes are px at this width
61
+ * style? screen-wide defaults: { fontFamily, fontSize, color, background }
62
+ * list? true, or { title, source, columns: [{ field, label }], pageSize, actions } —
63
+ * a list of the saved records below the fields, with New / Edit / Delete
64
+ *
65
+ * There is no submit button: a screen saves itself as it is filled in.
60
66
  */
61
67
  export function screenFromSpec(spec, controls = CONTROLS) {
62
68
  if (!spec || typeof spec !== 'object') throw new Error('screenFromSpec: spec must be an object')
@@ -66,7 +72,7 @@ export function screenFromSpec(spec, controls = CONTROLS) {
66
72
  const columns = spec.columns === undefined ? 2 : spec.columns
67
73
  if (columns !== 1 && columns !== 2) throw new Error('screenFromSpec: spec.columns must be 1 or 2')
68
74
 
69
- let doc = createScreen({ name: spec.name, id: spec.id, aspect: spec.aspect })
75
+ let doc = createScreen({ name: spec.name, id: spec.id, aspect: spec.aspect, width: spec.width, source: spec.source, style: spec.style })
70
76
  const full = 1 - 2 * MARGIN
71
77
  const colW = round((full - (columns - 1) * GAP) / columns)
72
78
  let y = MARGIN
@@ -75,7 +81,7 @@ export function screenFromSpec(spec, controls = CONTROLS) {
75
81
 
76
82
  const place = (type, props, width) => {
77
83
  const h = heightOf(type, props)
78
- const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label'
84
+ const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label' || type === 'list'
79
85
  if (isFull) {
80
86
  if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
81
87
  ;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
@@ -105,34 +111,35 @@ export function screenFromSpec(spec, controls = CONTROLS) {
105
111
 
106
112
  if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
107
113
 
108
- // One row of buttons, left-aligned. Narrow on purpose: a full-width Save
109
- // reads as a banner, not a button.
110
- const buttons = []
111
- if (spec.submit !== false) buttons.push({ label: typeof spec.submit === 'string' ? spec.submit : 'Save', action: 'submit' })
112
- if (spec.reset) buttons.push({ label: typeof spec.reset === 'string' ? spec.reset : 'Reset', action: 'reset' })
113
- const buttonW = 0.18
114
- buttons.forEach((props, i) => {
115
- const at = { x: round(MARGIN + i * (buttonW + GAP)), y: round(y) }
116
- ;({ document: doc } = addControl(doc, 'button', { at, size: { w: buttonW, h: SPEC_HEIGHTS.button }, props }, controls))
117
- })
114
+ if (spec.list) {
115
+ const list = spec.list === true ? {} : spec.list
116
+ if (typeof list !== 'object') throw new Error('screenFromSpec: spec.list must be true or an object')
117
+ unknownKeys(list, LIST_KEYS, 'spec.list')
118
+ const props = { title: 'Saved records', pageSize: 10, actions: ['new', 'edit', 'delete'], ...list }
119
+ place('list', props, 'full')
120
+ }
118
121
 
119
122
  return assertValidDocument(doc, controls)
120
123
  }
121
124
 
122
125
  function propsFromField(type, field, at) {
123
126
  if (type === 'label') {
124
- return { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
125
- }
126
- if (type === 'button') {
127
- return { label: field.label || 'Save', action: 'submit' }
127
+ const props = { text: field.text ?? field.label ?? '', variant: field.variant || 'subheading' }
128
+ if (field.style) props.style = field.style
129
+ return props
128
130
  }
129
131
  if (!field.label) throw new Error(`screenFromSpec: ${at}.label is required`)
130
132
  const props = { label: field.label }
131
- ;['name', 'required', 'placeholder', 'default', 'validation'].forEach((k) => {
133
+ ;['name', 'required', 'placeholder', 'default', 'validation', 'style'].forEach((k) => {
132
134
  if (field[k] !== undefined) props[k] = field[k]
133
135
  })
134
136
  if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
135
- if (type === 'dropdown') props.data = dataFromField(field, at)
137
+ if (type === 'dropdown') {
138
+ props.data = dataFromField(field, at)
139
+ // A dropdown reading another table stores that row's id: name the column
140
+ // for what it holds — "Department" → departmentId, a foreign key.
141
+ if (props.data.source === 'table' && props.name === undefined) props.name = camelName(field.label) + 'Id'
142
+ }
136
143
  else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
137
144
  return props
138
145
  }
@@ -165,3 +172,107 @@ function unknownKeys(obj, allowed, at) {
165
172
  function round(v) {
166
173
  return Math.round(v * 10000) / 10000
167
174
  }
175
+
176
+ // ── an entity: a LIST screen and an EDIT screen ──────────────────────────
177
+ //
178
+ // "Create a form for employees" is two screens, not one:
179
+ //
180
+ // employee_list — the saved employees, in a list; Edit and New open…
181
+ // employee_edit — …the add / edit form, in a popup (or on its own page)
182
+ //
183
+ // The list names the edit screen (`editScreen`), which is the only link
184
+ // between them; both save to and read from the same table.
185
+
186
+ const ENTITY_KEYS = ['entity', 'plural', 'source', 'fields', 'columns', 'listColumns', 'pageSize', 'actions', 'width', 'style', 'aspect']
187
+
188
+ /**
189
+ * @param spec
190
+ * entity singular name, e.g. "employee" (required)
191
+ * plural? e.g. "employees" (default: entity + "s")
192
+ * source? table records are saved in (default: plural, snake_cased)
193
+ * fields the edit form's fields — exactly as screenFromSpec
194
+ * columns? 1 or 2 — the edit form's layout (default 2)
195
+ * listColumns? [{ field, label }] or ["firstName", …] — the list's columns
196
+ * (default: up to the first 5 fields)
197
+ * pageSize?, actions?, width?, style?
198
+ * @returns {{ list, edit }} two checked screen documents
199
+ */
200
+ export function screensFromSpec(spec, controls = CONTROLS) {
201
+ if (!spec || typeof spec !== 'object') throw new Error('screensFromSpec: spec must be an object')
202
+ unknownKeys(spec, ENTITY_KEYS, 'spec')
203
+ if (!spec.entity || typeof spec.entity !== 'string') throw new Error('screensFromSpec: spec.entity is required, e.g. "employee"')
204
+ const names = entityNames(spec.entity, spec.plural)
205
+ const source = spec.source || names.table
206
+
207
+ const edit = screenFromSpec({
208
+ name: `Add / edit ${names.singular}`,
209
+ id: `${names.key}_edit`,
210
+ source,
211
+ columns: spec.columns,
212
+ fields: spec.fields,
213
+ width: spec.width,
214
+ style: spec.style,
215
+ aspect: spec.aspect
216
+ }, controls)
217
+
218
+ const fieldNodes = edit.nodes.filter((n) => controls[n.type]?.input)
219
+ const byName = Object.fromEntries(fieldNodes.map((n) => [n.props.name, n]))
220
+ let listColumns
221
+ if (Array.isArray(spec.listColumns) && spec.listColumns.length) {
222
+ listColumns = spec.listColumns.map((c, i) => {
223
+ const field = typeof c === 'string' ? c : c && c.field
224
+ if (!byName[field]) throw new Error(`screensFromSpec: spec.listColumns[${i}] "${field}" is not a field of the edit form — one of: ${Object.keys(byName).join(', ')}`)
225
+ return { field, label: (typeof c === 'object' && c.label) || byName[field].props.label }
226
+ })
227
+ } else {
228
+ listColumns = fieldNodes.slice(0, 5).map((n) => ({ field: n.props.name, label: n.props.label }))
229
+ }
230
+
231
+ let list = createScreen({ name: names.pluralTitle, id: `${names.key}_list`, source, width: spec.width, style: spec.style, aspect: spec.aspect })
232
+ ;({ document: list } = addControl(list, 'list', {
233
+ at: { x: MARGIN, y: MARGIN },
234
+ size: { w: round(1 - 2 * MARGIN), h: 0.8 },
235
+ props: {
236
+ title: names.pluralTitle,
237
+ editScreen: edit.id,
238
+ columns: listColumns,
239
+ pageSize: spec.pageSize || 10,
240
+ actions: spec.actions || ['new', 'edit', 'delete']
241
+ }
242
+ }, controls))
243
+
244
+ return { list: assertValidDocument(list, controls), edit }
245
+ }
246
+
247
+ /**
248
+ * "employee" → the names the pair uses everywhere, so the ids, files, table and
249
+ * components always agree.
250
+ */
251
+ export function entityNames(entity, plural) {
252
+ const words = String(entity).trim().replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
253
+ if (!words.length) throw new Error('entity must contain letters or digits')
254
+ const pluralWords = plural
255
+ ? String(plural).trim().replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
256
+ : [...words.slice(0, -1), pluralise(words[words.length - 1])]
257
+ const lower = (ws) => ws.map((w) => w.toLowerCase())
258
+ const pascal = (ws) => ws.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('')
259
+ return {
260
+ singular: lower(words).join(' '), // "employee"
261
+ pluralTitle: capitalise(lower(pluralWords).join(' ')), // "Employees"
262
+ key: lower(words).join('_'), // "employee" → ids employee_list / employee_edit
263
+ file: lower(words).join('-'), // "employee" → employee-list.screen.json
264
+ table: lower(pluralWords).join('_'), // "employees"
265
+ listComponent: `${pascal(words)}List`, // "EmployeeList"
266
+ editComponent: `Edit${pascal(words)}` // "EditEmployee"
267
+ }
268
+ }
269
+
270
+ function pluralise(word) {
271
+ if (/[^aeiou]y$/i.test(word)) return word.slice(0, -1) + 'ies'
272
+ if (/(s|x|z|ch|sh)$/i.test(word)) return word + 'es'
273
+ return word + 's'
274
+ }
275
+
276
+ function capitalise(s) {
277
+ return s.charAt(0).toUpperCase() + s.slice(1)
278
+ }
package/src/index.js CHANGED
@@ -10,10 +10,10 @@ export * from './model.js'
10
10
 
11
11
  // Controllers
12
12
  export { useFactoryBuilder } from './useFactoryBuilder.js'
13
- export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
13
+ export { useFactoryScreen, normaliseOptions, AUTOSAVE_DELAY } from './useFactoryScreen.js'
14
14
 
15
15
  // Designs
16
- export { BuilderSample, ScreenSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
16
+ export { BuilderSample, ScreenSample, ListView, ScreenModal, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
17
17
 
18
18
  // Ready-made
19
19
  export { FactoryBuilder, FactoryScreen } from './pages.jsx'