@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.
@@ -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
+ }
@@ -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
+ }