@xeplr/ui-factory 1.0.11 → 1.0.12

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/README.md CHANGED
@@ -235,6 +235,25 @@ class TaskHooks extends FactoryHooks {
235
235
  }
236
236
  ```
237
237
 
238
+ A screen with branches can **rule steps out** from the same hook — they stay on
239
+ the bar, struck through, and Next and Back pass over them:
240
+
241
+ ```js
242
+ step(ctx) {
243
+ if (ctx.from === 0 && ctx.values.kind === 'simple') {
244
+ ctx.disable(['pricing', 'approval']) // by key, label or number
245
+ return 'summary' // and go straight there
246
+ }
247
+ ctx.enable() // every step applies again
248
+ return super.step(ctx)
249
+ }
250
+ ```
251
+
252
+ `ctx.go`, `ctx.disable` and `ctx.enable` act on that stepper; `ctx.steps` is the
253
+ same for a screen with more than one (`ctx.steps.disable('approval', stepperId)`).
254
+ `ctx.steps` is on `save` and `get` too, since a save is usually where an app
255
+ learns which branch it is on. "Step 2 of 4" counts only the steps that apply.
256
+
238
257
  **A step is not a separate form.** Every field is a column, is checked, and is
239
258
  saved, whichever step is showing — a screen still saves itself as it is filled
240
259
  in. **Next** holds at a step whose own fields are not filled in, and says which,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -341,7 +341,8 @@ function StepperView({ node, p, design, stepper }) {
341
341
  <div className="xeplr-factory-stepper" style={{ ...fieldStyle(p.style), ...text }}>
342
342
  <ol className="xeplr-factory-steps">
343
343
  {steps.map((s, i) => {
344
- const state = i === active ? 'is-active' : i < active ? 'is-done' : ''
344
+ const off = Boolean(stepper && stepper.isDisabled && stepper.isDisabled(i))
345
+ const state = off ? 'is-off' : i === active ? 'is-active' : i < active ? 'is-done' : ''
345
346
  return (
346
347
  <li key={s.key} className={`xeplr-factory-step ${state}`} aria-current={i === active ? 'step' : undefined}>
347
348
  <button
@@ -349,7 +350,9 @@ function StepperView({ node, p, design, stepper }) {
349
350
  className="xeplr-factory-step-button"
350
351
  // On the canvas a press must still be able to drag the control,
351
352
  // so the step is taken on click, never on mousedown.
352
- onClick={(e) => { if (go) { e.stopPropagation(); go(i) } }}
353
+ onClick={(e) => { if (go && !off) { e.stopPropagation(); go(i) } }}
354
+ disabled={off}
355
+ aria-disabled={off || undefined}
353
356
  tabIndex={design ? -1 : undefined}
354
357
  >
355
358
  <span className="xeplr-factory-step-mark" aria-hidden="true">{i < active ? '✓' : (p.showNumbers === false ? '' : i + 1)}</span>
@@ -29,17 +29,26 @@ const STATUS = {
29
29
  */
30
30
  function StepNav({ ctrl, node, children }) {
31
31
  const at = ctrl.steps.active(node.id)
32
- const last = ctrl.steps.count(node.id) - 1
32
+ const total = ctrl.steps.count(node.id)
33
+ const off = ctrl.steps.disabled(node.id)
33
34
  const unfinished = ctrl.steps.fieldsOn(node.id, at).filter((n) => ctrl.allErrors[n.props.name])
35
+ // Back and Next go to the nearest step that is switched on, passing over any
36
+ // the app has ruled out; null means there is none that way.
37
+ const back = ctrl.steps.nextOpen(node.id, 'back')
38
+ const forward = ctrl.steps.nextOpen(node.id, 'next')
34
39
  const go = (to, direction) => {
35
- if (to > at && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
36
- ctrl.steps.go(node.id, Math.min(Math.max(to, 0), last), direction)
40
+ if (to === null) return
41
+ if (direction === 'next' && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
42
+ ctrl.steps.go(node.id, to, direction)
37
43
  }
44
+ // "Step 2 of 4" counts the steps that apply, not the ones ruled out.
45
+ const live = total - off.length
46
+ const place = at - off.filter((i) => i < at).length + 1
38
47
  return (
39
48
  <div className="xeplr-factory-stepnav">
40
- <button type="button" className="xeplr-factory-secondary" onClick={() => go(at - 1, 'back')} disabled={at === 0}>Back</button>
41
- <span className="xeplr-factory-stepnav-where">Step {at + 1} of {last + 1}</span>
42
- <button type="button" className="xeplr-factory-primary" onClick={() => go(at + 1, 'next')} disabled={at === last}>Next</button>
49
+ <button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
50
+ <span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
51
+ <button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={forward === null}>Next</button>
43
52
  {children}
44
53
  </div>
45
54
  )
@@ -116,7 +125,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
116
125
  error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
117
126
  options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
118
127
  upload={node.type === 'file' ? ctrl.upload : undefined}
119
- stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i, 'jump') } : undefined}
128
+ stepper={node.type === 'stepper' ? {
129
+ active: ctrl.steps.active(node.id),
130
+ isDisabled: (i) => ctrl.steps.isDisabled(node.id, i),
131
+ onStep: (i) => ctrl.steps.go(node.id, i, 'jump')
132
+ } : undefined}
120
133
  onChange={(raw) => ctrl.setValue(node, raw)}
121
134
  onBlur={() => ctrl.touch(node)}
122
135
  list={node.type === 'list' ? {
@@ -129,6 +129,10 @@
129
129
  .xeplr-factory-step.is-active .xeplr-factory-step-mark,
130
130
  .xeplr-factory-step.is-done .xeplr-factory-step-mark { background: var(--xf-accent); color: #fff; }
131
131
  .xeplr-factory-step.is-active .xeplr-factory-step-button { color: var(--xf-accent); font-weight: 600; }
132
+ /* A step the app has ruled out: still shown, so the journey reads the same,
133
+ but plainly not one of the stops. */
134
+ .xeplr-factory-step.is-off .xeplr-factory-step-button { opacity: 0.4; cursor: default; }
135
+ .xeplr-factory-step.is-off .xeplr-factory-step-label { text-decoration: line-through; }
132
136
 
133
137
  /* Back / Next, under the page. */
134
138
  .xeplr-factory-stepnav { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
package/src/document.js CHANGED
@@ -155,6 +155,46 @@ export function nodesForSteps(doc, active = {}, controls = CONTROLS) {
155
155
  return (doc.nodes || []).filter((n) => shown(n, new Set()))
156
156
  }
157
157
 
158
+ // Which step a move lands on. Pure, and the only place the rules live: a step
159
+ // may be named by its key, its label or its number; a step that is switched
160
+ // off is passed over, in the direction of travel, and only when there is
161
+ // nothing that way is the other direction tried.
162
+
163
+ /** A step named by key, label or number → its index, or -1. */
164
+ export function resolveStep(steps, which) {
165
+ if (Number.isInteger(which)) return which >= 0 && which < steps.length ? which : -1
166
+ return steps.findIndex((s) => s.key === which || s.label === which)
167
+ }
168
+
169
+ /**
170
+ * The step actually reached when aiming at `index`.
171
+ * @param disabled indexes switched off
172
+ * @param direction 'next' | 'back' | 'jump'
173
+ * @param from where we are now, for when nothing is open either way
174
+ */
175
+ export function reachableStep(count, index, disabled = [], direction = 'next', from = 0) {
176
+ if (count <= 0) return 0
177
+ const off = (i) => disabled.includes(i)
178
+ const clamp = (i) => Math.min(Math.max(i, 0), count - 1)
179
+ const way = direction === 'back' ? -1 : 1
180
+ let i = clamp(index)
181
+ while (i >= 0 && i < count && off(i)) i += way
182
+ if (i < 0 || i >= count) {
183
+ i = clamp(index)
184
+ while (i >= 0 && i < count && off(i)) i -= way
185
+ }
186
+ return i >= 0 && i < count ? i : clamp(from)
187
+ }
188
+
189
+ /** The nearest open step in this direction, or null when there is none. */
190
+ export function nextOpenStep(count, at, disabled = [], direction = 'next') {
191
+ const step = direction === 'back' ? -1 : 1
192
+ const aim = at + step
193
+ if (aim < 0 || aim >= count) return null
194
+ const i = reachableStep(count, aim, disabled, direction, at)
195
+ return i === at || disabled.includes(i) ? null : i
196
+ }
197
+
158
198
  /** Merges a geometry patch — what the canvas reports on drop. */
159
199
  export function moveNode(doc, id, patch) {
160
200
  const geometry = {}
package/src/hooks.js CHANGED
@@ -42,6 +42,8 @@ export class FactoryHooks {
42
42
  /**
43
43
  * Save — called on every autosave, so keep it quick and safe to repeat.
44
44
  * @param values what the form holds
45
+ * @param ctx.steps switch steps off or on as the record decides them —
46
+ * ctx.steps.disable('approval'), ctx.steps.enable()
45
47
  * @returns the saved record (its id makes the next save an update)
46
48
  */
47
49
  save(values, ctx) {
@@ -61,14 +63,32 @@ export class FactoryHooks {
61
63
  * return super.step(ctx)
62
64
  * }
63
65
  *
66
+ * A screen with branches decides which of them apply from here:
67
+ *
68
+ * step(ctx) {
69
+ * if (ctx.from === 0) {
70
+ * if (ctx.values.kind === 'simple') { ctx.disable(['pricing', 'approval']); return 'summary' }
71
+ * ctx.enable() // every step applies again
72
+ * }
73
+ * return super.step(ctx)
74
+ * }
75
+ *
64
76
  * @param ctx.from the step being left, counting from 0
65
77
  * @param ctx.to the step asked for
66
78
  * @param ctx.direction 'next' | 'back' | 'jump' (a click on the bar)
67
79
  * @param ctx.values what the form holds
68
80
  * @param ctx.stepper the stepper's node id
69
- * @returns false to stay where you are, a step number to go somewhere else,
70
- * anything else to move as asked. The step's own required fields are
71
- * checked before this runs, so this is for your rules, not theirs.
81
+ * @param ctx.go (step) → move there (a key or a number)
82
+ * @param ctx.disable (steps) → switch them off: shown, but passed over by
83
+ * Next and Back and not clickable. A key, a number, or
84
+ * a list of either.
85
+ * @param ctx.enable (steps) → switch them back on; no arguments, all of them
86
+ * @param ctx.steps the same, for a screen with more than one stepper:
87
+ * disable(which, stepperId), active(id), disabled(id)
88
+ * @returns false to stay where you are, a step (its key or its number) to go
89
+ * somewhere else, anything else to move as asked. The step's own
90
+ * required fields are checked before this runs, so this is for your
91
+ * rules, not theirs.
72
92
  */
73
93
  step(ctx) {
74
94
  return true
package/src/model.js CHANGED
@@ -8,7 +8,7 @@ export {
8
8
  DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
9
9
  createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
10
10
  inputNodes, readingOrder, contentBottom, camelName, slugify, uniqueFieldName, nextPosition,
11
- steppers, stepsOf, stepOf, setNodeStep, nodesForSteps
11
+ steppers, stepsOf, stepOf, setNodeStep, nodesForSteps, resolveStep, reachableStep, nextOpenStep
12
12
  } from './document.js'
13
13
  export { validateDocument, assertValidDocument } from './validateDocument.js'
14
14
  export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { CONTROLS, CHOICE_TYPES } from './controls.js'
3
- import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf } from './document.js'
3
+ import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reachableStep, nextOpenStep } from './document.js'
4
4
  import { validateDocument } from './validateDocument.js'
5
5
  import { initialValues, parseInput, saveState, fieldError, optionValue, recordValues, listSource } from './values.js'
6
6
  import { hookMethod } from './hooks.js'
@@ -152,6 +152,9 @@ export function useFactoryScreen({
152
152
  // the fields of the steps not showing are still filled in, still checked and
153
153
  // still saved — a step is what the person sees, not a separate form.
154
154
  const [openSteps, setOpenSteps] = useState({})
155
+ // Steps the app has switched off — { stepper id: [index, …] }. A screen may
156
+ // have branches; which of them apply is the app's to say, from its hooks.
157
+ const [disabledSteps, setDisabledSteps] = useState({})
155
158
  const stepperNodes = useMemo(() => (check.ok ? steppers(doc, controls) : []), [check.ok, doc, controls])
156
159
  const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
157
160
 
@@ -173,42 +176,104 @@ export function useFactoryScreen({
173
176
 
174
177
  const stepControl = useMemo(() => {
175
178
  const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
176
- return {
179
+ const only = () => (stepperNodes.length === 1 ? stepperNodes[0].id : null)
180
+ const nodeFor = (id) => stepperNodes.find((n) => n.id === id) || stepperNodes[0] || null
181
+ const lastOf = (id) => stepsOf(nodeFor(id)).length - 1
182
+ /** A step named by its key ("planning"), its label, or its number from 0. */
183
+ const indexOf = (id, which) => resolveStep(stepsOf(nodeFor(id)), which)
184
+ const offList = (id) => disabledSteps[id] || []
185
+ const isOff = (id, index) => offList(id).includes(index)
186
+ const reachable = (id, index, direction) => reachableStep(lastOf(id) + 1, index, offList(id), direction, at(id))
187
+ const setOff = (id, list) => setDisabledSteps((d) => ({ ...d, [id || only()]: list }))
188
+
189
+ const control = {
177
190
  nodes: stepperNodes,
178
191
  active: at,
179
- count: (id) => stepsOf(stepperNodes.find((n) => n.id === id)).length,
192
+ count: (id) => stepsOf(nodeFor(id)).length,
193
+ labels: (id) => stepsOf(nodeFor(id)),
194
+ indexOf,
195
+ /** Is this step switched off — shown, but not one you can be on? */
196
+ isDisabled: (id, index) => isOff(id, index),
197
+ disabled: (id) => (disabledSteps[id || only()] || []).slice(),
198
+ /**
199
+ * Switch steps off (a key, a number, or a list of them). A step that is
200
+ * off cannot be reached by Next, Back or a click, and Next passes over
201
+ * it. Switching off the step someone is on moves them to the nearest one
202
+ * that is open.
203
+ */
204
+ disable: (which, id) => {
205
+ const on = id || only()
206
+ if (!on) return []
207
+ const add = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w)).filter((i) => i >= 0)
208
+ const next = [...new Set([...(disabledSteps[on] || []), ...add])]
209
+ setOff(on, next)
210
+ return next
211
+ },
212
+ /** Switch them back on. No arguments switches every step of it back on. */
213
+ enable: (which, id) => {
214
+ const on = id || only()
215
+ if (!on) return []
216
+ if (which === undefined) { setOff(on, []); return [] }
217
+ const drop = (Array.isArray(which) ? which : [which]).map((w) => indexOf(on, w))
218
+ const next = (disabledSteps[on] || []).filter((i) => !drop.includes(i))
219
+ setOff(on, next)
220
+ return next
221
+ },
180
222
  /**
181
223
  * Move, if the app's `step` hook lets it: false keeps the person where
182
224
  * they are, a number sends them somewhere else. The hook runs for Next,
183
225
  * Back and a click on the bar alike, so a rule cannot be walked around.
184
226
  */
185
227
  go: async (id, index, direction) => {
186
- const from = at(id)
187
- const last = stepsOf(stepperNodes.find((n) => n.id === id)).length - 1
188
- const to = Math.min(Math.max(index, 0), Math.max(last, 0))
228
+ const on = id || only()
229
+ const from = at(on)
230
+ const way = direction || (index > from ? 'next' : 'back')
231
+ const to = reachable(on, indexOf(on, index), way)
189
232
  if (to === from) return from
190
233
  const decide = hookMethod(hooksRef.current, 'step')
191
234
  const answer = await decide({
192
235
  from,
193
236
  to,
194
- direction: direction || (to > from ? 'next' : 'back'),
237
+ direction: way,
195
238
  values: live.current.values,
196
- stepper: id,
239
+ stepper: on,
197
240
  screen: doc.id,
198
- document: doc
241
+ document: doc,
242
+ // What the hook can DO from here: send them somewhere, or say which
243
+ // steps this answer makes beside the point.
244
+ steps: control,
245
+ go: (where) => control.go(on, where, 'jump'),
246
+ disable: (which) => control.disable(which, on),
247
+ enable: (which) => control.enable(which, on)
199
248
  })
200
249
  if (answer === false) return from
201
- const where = Number.isInteger(answer) ? Math.min(Math.max(answer, 0), Math.max(last, 0)) : to
202
- setOpenSteps((o) => ({ ...o, [id]: where }))
250
+ const asked = Number.isInteger(answer) || typeof answer === 'string' ? indexOf(on, answer) : to
251
+ const where = reachable(on, asked >= 0 ? asked : to, way)
252
+ setOpenSteps((o) => ({ ...o, [on]: where }))
203
253
  return where
204
254
  },
255
+ /** The next step in this direction that is open, for Back and Next. */
256
+ nextOpen: (id, direction) => {
257
+ const on = id || only()
258
+ return nextOpenStep(lastOf(on) + 1, at(on), offList(on), direction)
259
+ },
205
260
  /** The fields on this step, so Next can hold at a step that is not filled in. */
206
261
  fieldsOn: (id, index) => inputNodes(doc, controls).filter((n) => {
207
262
  const step = stepOf(n)
208
- return step && step.of === id && step.index === index
263
+ return step && step.of === (id || only()) && step.index === index
209
264
  })
210
265
  }
211
- }, [stepperNodes, openSteps, doc, controls])
266
+ return control
267
+ }, [stepperNodes, openSteps, disabledSteps, doc, controls])
268
+
269
+ live.current.steps = stepControl
270
+
271
+ // A step that is switched off cannot be the one showing.
272
+ useEffect(() => {
273
+ stepperNodes.forEach((n) => {
274
+ if (stepControl.isDisabled(n.id, stepControl.active(n.id))) stepControl.go(n.id, stepControl.active(n.id) + 1, 'next')
275
+ })
276
+ }, [stepperNodes, stepControl])
212
277
 
213
278
  // ── a file field's upload ─────────────────────────────────────────────
214
279
  const uploadRef = useRef(uploadFile); uploadRef.current = uploadFile
@@ -257,6 +322,9 @@ export function useFactoryScreen({
257
322
  const saved = await hook('save')(l.values, {
258
323
  ...meta,
259
324
  isNew: l.recordId === null || l.recordId === undefined,
325
+ // Save is where an app usually learns which branch it is on, so it
326
+ // can switch steps off from here as well as from step().
327
+ steps: live.current.steps,
260
328
  defaults: { save: (vals) => (l.onSave ? l.onSave(vals, meta) : undefined) }
261
329
  })
262
330
  // A new record's first save creates it; its id makes the next save an update.
@@ -376,6 +444,7 @@ export function useFactoryScreen({
376
444
  setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], loading: true, error: null } }))
377
445
  const run = Promise.resolve().then(() => hook('get')({
378
446
  many: true, id: null, screen: doc.id, source: src, node, document: doc,
447
+ steps: live.current.steps,
379
448
  defaults: {
380
449
  get: () => {
381
450
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)