@xeplr/ui-factory 1.0.1
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.
- package/AUTHORING.md +133 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/xeplr-factory.js +98 -0
- package/package.json +59 -0
- package/src/controls.js +238 -0
- package/src/designs/BuilderSample.jsx +117 -0
- package/src/designs/ControlView.jsx +192 -0
- package/src/designs/Palette.jsx +34 -0
- package/src/designs/PropertyPanel.jsx +228 -0
- package/src/designs/ScreenSample.jsx +65 -0
- package/src/designs/factory.css +177 -0
- package/src/designs/index.js +7 -0
- package/src/document.js +215 -0
- package/src/generate.js +167 -0
- package/src/index.js +19 -0
- package/src/model.js +15 -0
- package/src/pages.jsx +42 -0
- package/src/propertyPath.js +35 -0
- package/src/useFactoryBuilder.js +209 -0
- package/src/useFactoryScreen.js +155 -0
- package/src/validateDocument.js +202 -0
- package/src/values.js +148 -0
package/src/document.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// THE SCREEN DOCUMENT — the metadata a screen is saved as, and every change
|
|
2
|
+
// the builder makes to it. Pure and immutable: each function returns a new
|
|
3
|
+
// document. No React, no storage; the consuming app stores documents.
|
|
4
|
+
//
|
|
5
|
+
// {
|
|
6
|
+
// kind: 'xeplr-screen', version: 1,
|
|
7
|
+
// id: 'new_employee', name: 'New employee',
|
|
8
|
+
// units: 'fraction', aspect: 1,
|
|
9
|
+
// nodes: [ { id, type, x, y, w, h, z?, groupId?, props } ]
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// ── GEOMETRY ─────────────────────────────────────────────────────────────
|
|
13
|
+
// Proportional, like the dashboards: x and w are fractions of the screen's
|
|
14
|
+
// WIDTH; y and h are fractions of one PAGE, and a page is width × aspect tall.
|
|
15
|
+
// With aspect 1 every number is simply a fraction of the width, so a screen
|
|
16
|
+
// keeps exactly its proportions at any size. y may exceed 1 — a long form runs
|
|
17
|
+
// onto a second page.
|
|
18
|
+
|
|
19
|
+
import { CONTROLS } from './controls.js'
|
|
20
|
+
import { getAtPath, setAtPath } from './propertyPath.js'
|
|
21
|
+
|
|
22
|
+
export const DOCUMENT_KIND = 'xeplr-screen'
|
|
23
|
+
export const DOCUMENT_VERSION = 1
|
|
24
|
+
export const DEFAULT_ASPECT = 1
|
|
25
|
+
|
|
26
|
+
/** Space kept around and between controls, as fractions. */
|
|
27
|
+
export const MARGIN = 0.04
|
|
28
|
+
export const GAP = 0.025
|
|
29
|
+
|
|
30
|
+
/** A new, empty screen. */
|
|
31
|
+
export function createScreen({ name, id, aspect } = {}) {
|
|
32
|
+
const title = String(name || 'Untitled screen').trim() || 'Untitled screen'
|
|
33
|
+
return {
|
|
34
|
+
kind: DOCUMENT_KIND,
|
|
35
|
+
version: DOCUMENT_VERSION,
|
|
36
|
+
id: id || slugify(title, '_') || 'screen',
|
|
37
|
+
name: title,
|
|
38
|
+
units: 'fraction',
|
|
39
|
+
aspect: aspect > 0 ? aspect : DEFAULT_ASPECT,
|
|
40
|
+
nodes: []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renameScreen(doc, name) {
|
|
45
|
+
return { ...doc, name: String(name == null ? '' : name) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Adds a control of `type`. Placed at `at` ({ x, y } — the drop point, as the
|
|
50
|
+
* control's top-left) or, without one, below everything already on the screen.
|
|
51
|
+
* Returns { document, node }.
|
|
52
|
+
*/
|
|
53
|
+
export function addControl(doc, type, { at, props, size } = {}, controls = CONTROLS) {
|
|
54
|
+
const def = controls[type]
|
|
55
|
+
if (!def) throw new Error(`Unknown control type "${type}"`)
|
|
56
|
+
const dims = { ...def.defaultSize, ...size }
|
|
57
|
+
const pos = at ? clampPosition(at, dims) : nextPosition(doc, dims)
|
|
58
|
+
const merged = { ...clone(def.defaults), ...props }
|
|
59
|
+
if (def.input) {
|
|
60
|
+
const base = merged.name || camelName(merged.label) || type
|
|
61
|
+
merged.name = uniqueFieldName(doc, base)
|
|
62
|
+
}
|
|
63
|
+
const node = {
|
|
64
|
+
id: uniqueNodeId(doc, def.input ? merged.name : type),
|
|
65
|
+
type,
|
|
66
|
+
x: pos.x,
|
|
67
|
+
y: pos.y,
|
|
68
|
+
w: dims.w,
|
|
69
|
+
h: dims.h,
|
|
70
|
+
z: nextZ(doc),
|
|
71
|
+
props: merged
|
|
72
|
+
}
|
|
73
|
+
return { document: { ...doc, nodes: [...doc.nodes, node] }, node }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Merges a geometry patch — what the canvas reports on drop. */
|
|
77
|
+
export function moveNode(doc, id, patch) {
|
|
78
|
+
const geometry = {}
|
|
79
|
+
;['x', 'y', 'w', 'h', 'z'].forEach((k) => { if (typeof patch[k] === 'number') geometry[k] = round(patch[k]) })
|
|
80
|
+
return mapNode(doc, id, (n) => ({ ...n, ...geometry }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sets one property by path on a node — `props.validation.maxLength`.
|
|
85
|
+
*
|
|
86
|
+
* Renaming an input's LABEL also renames its field name, for as long as that
|
|
87
|
+
* name is still the one made for it: a dropdown dropped as "Dropdown" and
|
|
88
|
+
* relabelled "Location" should save as `location`, not `dropdown`. A name
|
|
89
|
+
* someone typed is theirs, and is left alone.
|
|
90
|
+
*/
|
|
91
|
+
export function setNodeProperty(doc, id, path, value, controls = CONTROLS) {
|
|
92
|
+
const node = doc.nodes.find((n) => n.id === id)
|
|
93
|
+
const followLabel = path === 'props.label' && node && controls[node.type]?.input && isAutoName(node, controls)
|
|
94
|
+
let next = mapNode(doc, id, (n) => {
|
|
95
|
+
const updated = setAtPath(n, path, value)
|
|
96
|
+
// setAtPath prunes an emptied object; `props` itself must always exist.
|
|
97
|
+
return updated.props ? updated : { ...updated, props: {} }
|
|
98
|
+
})
|
|
99
|
+
if (followLabel) {
|
|
100
|
+
const base = camelName(value) || node.type
|
|
101
|
+
const others = { ...next, nodes: next.nodes.filter((n) => n.id !== id) }
|
|
102
|
+
next = mapNode(next, id, (n) => ({ ...n, props: { ...n.props, name: uniqueFieldName(others, base) } }))
|
|
103
|
+
}
|
|
104
|
+
return next
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is this input's name still the one generated from its label or type? */
|
|
108
|
+
function isAutoName(node, controls) {
|
|
109
|
+
const name = node.props?.name
|
|
110
|
+
if (!name) return true
|
|
111
|
+
const stem = name.replace(/\d+$/, '')
|
|
112
|
+
return stem === camelName(node.props.label) || stem === node.type || stem === camelName(controls[node.type]?.defaults?.label)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getNodeProperty(node, path) {
|
|
116
|
+
return getAtPath(node, path)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function removeNodes(doc, ids) {
|
|
120
|
+
const gone = new Set(ids)
|
|
121
|
+
return { ...doc, nodes: doc.nodes.filter((n) => !gone.has(n.id)) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The nodes that hold a value, in reading order (top to bottom, left to right). */
|
|
125
|
+
export function inputNodes(doc, controls = CONTROLS) {
|
|
126
|
+
return readingOrder(doc.nodes.filter((n) => controls[n.type]?.input))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function readingOrder(nodes) {
|
|
130
|
+
// Rows are compared with a little tolerance: two fields side by side are
|
|
131
|
+
// rarely at EXACTLY the same y after a drag.
|
|
132
|
+
return [...nodes].sort((a, b) => (Math.abs(a.y - b.y) > 0.02 ? a.y - b.y : a.x - b.x))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Where the lowest control ends, as a page fraction. */
|
|
136
|
+
export function contentBottom(doc) {
|
|
137
|
+
return doc.nodes.reduce((max, n) => Math.max(max, (n.y || 0) + (n.h || 0)), 0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── naming ───────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** "Date of birth" → "dateOfBirth". What a field is saved under. */
|
|
143
|
+
export function camelName(label) {
|
|
144
|
+
const words = String(label || '')
|
|
145
|
+
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
146
|
+
.replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean)
|
|
147
|
+
if (!words.length) return ''
|
|
148
|
+
const name = words.map((w, i) => {
|
|
149
|
+
const lower = w.toLowerCase()
|
|
150
|
+
return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1)
|
|
151
|
+
}).join('')
|
|
152
|
+
return /^[0-9]/.test(name) ? '_' + name : name
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function slugify(text, sep = '-') {
|
|
156
|
+
return String(text || '')
|
|
157
|
+
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
158
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, sep).replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function uniqueFieldName(doc, base) {
|
|
162
|
+
const taken = new Set(doc.nodes.map((n) => n.props?.name).filter(Boolean))
|
|
163
|
+
return uniqueOf(base, taken)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function uniqueNodeId(doc, base) {
|
|
167
|
+
const taken = new Set(doc.nodes.map((n) => n.id))
|
|
168
|
+
return uniqueOf(base, taken)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function uniqueOf(base, taken) {
|
|
172
|
+
if (!taken.has(base)) return base
|
|
173
|
+
let i = 2
|
|
174
|
+
while (taken.has(base + i)) i++
|
|
175
|
+
return base + i
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── placement ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Below everything already placed, at the left margin. */
|
|
181
|
+
export function nextPosition(doc, size) {
|
|
182
|
+
const bottom = contentBottom(doc)
|
|
183
|
+
return clampPosition({ x: MARGIN, y: bottom ? bottom + GAP : MARGIN }, size)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function clampPosition(at, size) {
|
|
187
|
+
const w = Math.min(1, size.w)
|
|
188
|
+
return {
|
|
189
|
+
x: round(Math.min(Math.max(0, at.x), 1 - w)),
|
|
190
|
+
y: round(Math.max(0, at.y))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function nextZ(doc) {
|
|
195
|
+
return doc.nodes.reduce((max, n) => Math.max(max, n.z || 0), 0) + 1
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mapNode(doc, id, fn) {
|
|
199
|
+
let found = false
|
|
200
|
+
const nodes = doc.nodes.map((n) => {
|
|
201
|
+
if (n.id !== id) return n
|
|
202
|
+
found = true
|
|
203
|
+
return fn(n)
|
|
204
|
+
})
|
|
205
|
+
return found ? { ...doc, nodes } : doc
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Four decimals: well under a pixel at any real width, and a diff stays readable. */
|
|
209
|
+
export function round(v) {
|
|
210
|
+
return Math.round(v * 10000) / 10000
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function clone(v) {
|
|
214
|
+
return v == null ? v : JSON.parse(JSON.stringify(v))
|
|
215
|
+
}
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// FROM A REQUEST TO A SCREEN.
|
|
2
|
+
//
|
|
3
|
+
// Screens are usually started by Claude from a plain-English request — "a form
|
|
4
|
+
// for a new employee with name, email, department…" — and then adjusted by a
|
|
5
|
+
// person in the builder. Writing fractional coordinates for every control by
|
|
6
|
+
// hand is exactly the part of that an assistant gets subtly wrong (overlaps, a
|
|
7
|
+
// field running off the edge), so this takes a SPEC — what is on the screen,
|
|
8
|
+
// in order — and does the layout: a tidy one- or two-column form.
|
|
9
|
+
//
|
|
10
|
+
// screenFromSpec({
|
|
11
|
+
// name: 'New employee',
|
|
12
|
+
// fields: [
|
|
13
|
+
// { label: 'First name', required: true },
|
|
14
|
+
// { label: 'Last name', required: true },
|
|
15
|
+
// { label: 'Email', validation: { pattern: '^\\S+@\\S+$', patternMessage: 'Enter a valid email' } },
|
|
16
|
+
// { label: 'Department', type: 'dropdown', table: 'departments', required: true },
|
|
17
|
+
// { label: 'Employment type', type: 'dropdown', options: ['Full time', 'Part time'] },
|
|
18
|
+
// { label: 'Start date', type: 'date' },
|
|
19
|
+
// { label: 'Notes', type: 'textarea' }
|
|
20
|
+
// ]
|
|
21
|
+
// })
|
|
22
|
+
//
|
|
23
|
+
// The result is an ordinary document, checked before it is returned; from
|
|
24
|
+
// there it is edited like any other.
|
|
25
|
+
|
|
26
|
+
import { CONTROLS } from './controls.js'
|
|
27
|
+
import { createScreen, addControl, MARGIN, GAP, slugify } from './document.js'
|
|
28
|
+
import { assertValidDocument } from './validateDocument.js'
|
|
29
|
+
|
|
30
|
+
/** Heights by control, as page fractions (a page is as tall as it is wide). */
|
|
31
|
+
export const SPEC_HEIGHTS = {
|
|
32
|
+
text: 0.08, number: 0.08, date: 0.08, dropdown: 0.08,
|
|
33
|
+
textarea: 0.18,
|
|
34
|
+
checkbox: 0.06,
|
|
35
|
+
button: 0.07,
|
|
36
|
+
label: { heading: 0.07, subheading: 0.055, text: 0.045 }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Space between ROWS. Wider than the gap between columns: a field's error
|
|
41
|
+
* message is drawn in the space below it, and must not touch the next label.
|
|
42
|
+
*/
|
|
43
|
+
export const ROW_GAP = 0.04
|
|
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']
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param spec
|
|
50
|
+
* name screen title (required)
|
|
51
|
+
* id? document id — defaults to the name, snake_cased
|
|
52
|
+
* columns? 1 or 2 (default 2)
|
|
53
|
+
* heading? true (default) puts the name at the top; a string uses that text; false for none
|
|
54
|
+
* fields in reading order. Each: { label, type?='text', name?, required?, placeholder?,
|
|
55
|
+
* default?, validation?, width?: 'half'|'full' }
|
|
56
|
+
* dropdown: options: ['Full time', …] or [{ id, name }] —or— table: 'departments'
|
|
57
|
+
* 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
|
|
60
|
+
*/
|
|
61
|
+
export function screenFromSpec(spec, controls = CONTROLS) {
|
|
62
|
+
if (!spec || typeof spec !== 'object') throw new Error('screenFromSpec: spec must be an object')
|
|
63
|
+
unknownKeys(spec, SPEC_KEYS, 'spec')
|
|
64
|
+
if (!spec.name || typeof spec.name !== 'string') throw new Error('screenFromSpec: spec.name is required, e.g. "New employee"')
|
|
65
|
+
if (!Array.isArray(spec.fields)) throw new Error('screenFromSpec: spec.fields must be an array')
|
|
66
|
+
const columns = spec.columns === undefined ? 2 : spec.columns
|
|
67
|
+
if (columns !== 1 && columns !== 2) throw new Error('screenFromSpec: spec.columns must be 1 or 2')
|
|
68
|
+
|
|
69
|
+
let doc = createScreen({ name: spec.name, id: spec.id, aspect: spec.aspect })
|
|
70
|
+
const full = 1 - 2 * MARGIN
|
|
71
|
+
const colW = round((full - (columns - 1) * GAP) / columns)
|
|
72
|
+
let y = MARGIN
|
|
73
|
+
let col = 0
|
|
74
|
+
let rowH = 0
|
|
75
|
+
|
|
76
|
+
const place = (type, props, width) => {
|
|
77
|
+
const h = heightOf(type, props)
|
|
78
|
+
const isFull = width === 'full' || columns === 1 || type === 'textarea' || type === 'label'
|
|
79
|
+
if (isFull) {
|
|
80
|
+
if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
81
|
+
;({ document: doc } = addControl(doc, type, { at: { x: MARGIN, y: round(y) }, size: { w: round(full), h }, props }, controls))
|
|
82
|
+
y += h + ROW_GAP
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const x = MARGIN + col * (colW + GAP)
|
|
86
|
+
;({ document: doc } = addControl(doc, type, { at: { x: round(x), y: round(y) }, size: { w: colW, h }, props }, controls))
|
|
87
|
+
rowH = Math.max(rowH, h)
|
|
88
|
+
col++
|
|
89
|
+
if (col === columns) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (spec.heading !== false) {
|
|
93
|
+
place('label', { text: typeof spec.heading === 'string' ? spec.heading : spec.name, variant: 'heading' }, 'full')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
spec.fields.forEach((field, i) => {
|
|
97
|
+
const at = `spec.fields[${i}]`
|
|
98
|
+
if (!field || typeof field !== 'object') throw new Error(`screenFromSpec: ${at} must be an object`)
|
|
99
|
+
unknownKeys(field, FIELD_KEYS, at)
|
|
100
|
+
const type = field.type || 'text'
|
|
101
|
+
if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control — one of: ${Object.keys(controls).join(', ')}`)
|
|
102
|
+
if (field.width !== undefined && field.width !== 'half' && field.width !== 'full') throw new Error(`screenFromSpec: ${at}.width must be "half" or "full"`)
|
|
103
|
+
place(type, propsFromField(type, field, at), field.width)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
107
|
+
|
|
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
|
+
})
|
|
118
|
+
|
|
119
|
+
return assertValidDocument(doc, controls)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function propsFromField(type, field, at) {
|
|
123
|
+
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' }
|
|
128
|
+
}
|
|
129
|
+
if (!field.label) throw new Error(`screenFromSpec: ${at}.label is required`)
|
|
130
|
+
const props = { label: field.label }
|
|
131
|
+
;['name', 'required', 'placeholder', 'default', 'validation'].forEach((k) => {
|
|
132
|
+
if (field[k] !== undefined) props[k] = field[k]
|
|
133
|
+
})
|
|
134
|
+
if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
|
|
135
|
+
if (type === 'dropdown') props.data = dataFromField(field, at)
|
|
136
|
+
else if (field.options || field.table || field.data) throw new Error(`screenFromSpec: ${at} — options/table/data only apply to a dropdown`)
|
|
137
|
+
return props
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function dataFromField(field, at) {
|
|
141
|
+
if (field.data) return field.data
|
|
142
|
+
if (field.table) return { source: 'table', table: field.table }
|
|
143
|
+
if (Array.isArray(field.options)) {
|
|
144
|
+
return {
|
|
145
|
+
source: 'static',
|
|
146
|
+
options: field.options.map((o) => (typeof o === 'string'
|
|
147
|
+
? { id: slugify(o, '_') || o, name: o }
|
|
148
|
+
: o))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`screenFromSpec: ${at} is a dropdown — give it options: [...] or table: "<name>"`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function heightOf(type, props) {
|
|
155
|
+
const h = SPEC_HEIGHTS[type]
|
|
156
|
+
if (type === 'label') return h[props.variant] || h.text
|
|
157
|
+
return h || 0.08
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unknownKeys(obj, allowed, at) {
|
|
161
|
+
const extra = Object.keys(obj).filter((k) => !allowed.includes(k))
|
|
162
|
+
if (extra.length) throw new Error(`screenFromSpec: ${at} has unknown key(s) ${extra.join(', ')} — allowed: ${allowed.join(', ')}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function round(v) {
|
|
166
|
+
return Math.round(v * 10000) / 10000
|
|
167
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @xeplr/ui-factory — design data-entry screens on @xeplr/ui-canvas, save them
|
|
2
|
+
// as JSON, render them as working forms.
|
|
3
|
+
//
|
|
4
|
+
// Three ways in, per the xeplr-ui-* convention:
|
|
5
|
+
// 1. <FactoryBuilder> / <FactoryScreen> as they are
|
|
6
|
+
// 2. useFactoryBuilder / useFactoryScreen with your own design
|
|
7
|
+
// 3. the model alone — '@xeplr/ui-factory/model', no React, runs in node
|
|
8
|
+
|
|
9
|
+
export * from './model.js'
|
|
10
|
+
|
|
11
|
+
// Controllers
|
|
12
|
+
export { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
13
|
+
export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
|
|
14
|
+
|
|
15
|
+
// Designs
|
|
16
|
+
export { BuilderSample, ScreenSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
17
|
+
|
|
18
|
+
// Ready-made
|
|
19
|
+
export { FactoryBuilder, FactoryScreen } from './pages.jsx'
|
package/src/model.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The model alone — no React, no CSS. Runs in node: a server checking a
|
|
2
|
+
// submission, the CLI, tests, or Claude writing a screen.
|
|
3
|
+
//
|
|
4
|
+
// import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
|
|
5
|
+
|
|
6
|
+
export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, BUTTON_ACTIONS, controlGroups, withControls } from './controls.js'
|
|
7
|
+
export {
|
|
8
|
+
DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, MARGIN, GAP,
|
|
9
|
+
createScreen, renameScreen, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
|
|
10
|
+
inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition
|
|
11
|
+
} from './document.js'
|
|
12
|
+
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
13
|
+
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue } from './values.js'
|
|
14
|
+
export { screenFromSpec, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
15
|
+
export { getAtPath, setAtPath } from './propertyPath.js'
|
package/src/pages.jsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
2
|
+
import { useFactoryScreen } from './useFactoryScreen.js'
|
|
3
|
+
import { BuilderSample, ScreenSample } from './designs/index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The builder, ready-made.
|
|
7
|
+
*
|
|
8
|
+
* @prop document the screen to edit (omit for a new one)
|
|
9
|
+
* @prop name name for a new screen
|
|
10
|
+
* @prop onSave async (document) → void — the app stores it; only called with a valid document
|
|
11
|
+
* @prop onChange (document) → void — every edit
|
|
12
|
+
* @prop listTables async () → [{ id, name }] | string[] — for "dropdown from a table"
|
|
13
|
+
* @prop fetchOptions async ({ table }) → [{ id, name }] — enables Preview with real options
|
|
14
|
+
* @prop controls control registry (default: built-ins)
|
|
15
|
+
*/
|
|
16
|
+
export function FactoryBuilder(props) {
|
|
17
|
+
const ctrl = useFactoryBuilder(props)
|
|
18
|
+
return (
|
|
19
|
+
<BuilderSample
|
|
20
|
+
ctrl={ctrl}
|
|
21
|
+
className={props.className}
|
|
22
|
+
style={props.style}
|
|
23
|
+
renderPreview={(doc) => (
|
|
24
|
+
<FactoryScreen document={doc} fetchOptions={props.fetchOptions} controls={props.controls} />
|
|
25
|
+
)}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A saved screen, running.
|
|
32
|
+
*
|
|
33
|
+
* @prop document a screen document (an invalid one renders its problems, loudly)
|
|
34
|
+
* @prop fetchOptions async ({ table }) → [{ id, name }] — rows for table dropdowns
|
|
35
|
+
* @prop onSubmit async (values, { document }) → void
|
|
36
|
+
* @prop values starting values, e.g. the record being edited
|
|
37
|
+
* @prop controls control registry (default: built-ins)
|
|
38
|
+
*/
|
|
39
|
+
export function FactoryScreen(props) {
|
|
40
|
+
const ctrl = useFactoryScreen(props)
|
|
41
|
+
return <ScreenSample ctrl={ctrl} className={props.className} style={props.style} />
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Read and write a value by dotted path — `props.validation.maxLength`. The
|
|
2
|
+
// property panel addresses every field this way, so a new property is a path in
|
|
3
|
+
// controls.js and never a new setter.
|
|
4
|
+
//
|
|
5
|
+
// Writes are immutable: every object along the path is copied, nothing else is.
|
|
6
|
+
|
|
7
|
+
export function getAtPath(obj, path) {
|
|
8
|
+
return String(path).split('.').reduce((cur, key) => (cur == null ? undefined : cur[key]), obj)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sets `value` at `path`. A BLANK value — undefined, '' or an empty object left
|
|
13
|
+
* behind — removes the key instead, so clearing "Max length" leaves no
|
|
14
|
+
* `maxLength: ''` in the saved document for a validator to trip over.
|
|
15
|
+
*/
|
|
16
|
+
export function setAtPath(obj, path, value) {
|
|
17
|
+
const keys = String(path).split('.')
|
|
18
|
+
const [head, ...rest] = keys
|
|
19
|
+
const base = obj && typeof obj === 'object' ? obj : {}
|
|
20
|
+
if (!rest.length) {
|
|
21
|
+
const next = { ...base }
|
|
22
|
+
if (isBlank(value)) delete next[head]
|
|
23
|
+
else next[head] = value
|
|
24
|
+
return next
|
|
25
|
+
}
|
|
26
|
+
const child = setAtPath(base[head], rest.join('.'), value)
|
|
27
|
+
const next = { ...base }
|
|
28
|
+
if (child && typeof child === 'object' && !Array.isArray(child) && !Object.keys(child).length) delete next[head]
|
|
29
|
+
else next[head] = child
|
|
30
|
+
return next
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isBlank(v) {
|
|
34
|
+
return v === undefined || v === ''
|
|
35
|
+
}
|