@xeplr/ui-factory 1.1.0 → 1.2.0

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
@@ -44,10 +44,10 @@ Most internal apps are a long tail of forms, and every one of them is hand-built
44
44
  ## Install
45
45
 
46
46
  ```sh
47
- npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table
47
+ npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table react-router-dom
48
48
  ```
49
49
 
50
- Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on) and [`@xeplr/ui-table`](https://www.npmjs.com/package/@xeplr/ui-table) (lists of saved records). Your bundler must compile JSX and import CSS from the package (Vite does both).
50
+ Peer dependencies: `react ^18 || ^19`, [`@xeplr/ui-canvas`](https://www.npmjs.com/package/@xeplr/ui-canvas) (the canvas the builder places controls on), [`@xeplr/ui-table`](https://www.npmjs.com/package/@xeplr/ui-table) (lists of saved records) and `react-router-dom ^6 || ^7` (imported by [`@xeplr/ui-utils`](https://www.npmjs.com/package/@xeplr/ui-utils)' `useUrlState`, a dependency, for the URL state below; with no router above, everything stays in memory). Your bundler must compile JSX and import CSS from the package (Vite does both).
51
51
 
52
52
  ## The builder
53
53
 
@@ -486,6 +486,26 @@ npx xeplr-factory controls # controls, props and rules
486
486
 
487
487
  Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are fractions of the width, `y` and `h` fractions of a page (`width × aspect`). The screen is shown at its design `width`; on a narrower display everything — positions and text — scales down together.
488
488
 
489
+ ## Where you are lives in the URL: `urlKey`
490
+
491
+ The control whose properties are open, the step a stepper is open at, and the flow step being edited are places a person navigates to. Give a controller (or its ready-made page) a `urlKey` and they are written to the URL's query, so a refresh or a shared link lands on them again:
492
+
493
+ | controller / page | key | holds | history |
494
+ |---|---|---|---|
495
+ | `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Selected` | the id of the one selected control | opening or closing its properties adds an entry (Back closes them); moving to another control replaces it |
496
+ | `useFactoryBuilder` / `<FactoryBuilder>` | `<urlKey>Steps` | the step each stepper is open at: `stepperId:index`, comma-separated, step 0 left out | replaces |
497
+ | `useFactoryScreen` / `<FactoryScreen>`, `<FlowRunner>` | `<urlKey>Steps` | as above | replaces |
498
+ | `useFlowBuilder` / `<FlowBuilder>` | `<urlKey>Selected` | the key of the selected flow step | as for a control |
499
+
500
+ ```jsx
501
+ <FactoryBuilder urlKey="form" document={doc} onSave={save} /> // ?formSelected=email&formSteps=stepper:1
502
+ ```
503
+
504
+ - **The key is the host's, never a default.** A builder, its Preview and a list's Edit popup can share a page, and the host has keys of its own, so nothing is written to the URL without a `urlKey` — give each its own. **Without one the state stays in memory**, as before. The Preview and the Edit popup are never given one.
505
+ - **A multi-selection is not a place.** Several controls selected is a working set for Delete and Duplicate, not somewhere a person goes, so it stays in memory and the URL then names nothing. Only a single selection — the one whose properties are open — is in the URL.
506
+ - **Ids, found again.** The selected control is looked up in the document; one that is not there selects nothing. A document handed in after mounting keeps the URL's control, so a host that loads the screen late still lands on it. A step past the last is pulled back to the last, and steppers not in the document (a screen still loading) are left alone.
507
+ - The rule is in `src/urlState.js` (`factoryUrlKeys`, `encodeSteps`, `decodeSteps`, `replacesHistory`, `selectionPlace`); the hooks that keep it are `src/useUrlPlaces.js`.
508
+
489
509
  ## Three ways to use it
490
510
 
491
511
  1. **Ready-made** — `<FactoryBuilder>` and `<FactoryScreen>`.
@@ -523,6 +543,9 @@ src/
523
543
  model.js ─ everything above, React-free
524
544
  useFactoryBuilder.js ─ builder controller
525
545
  useFactoryScreen.js ─ screen controller
546
+ useFlowBuilder.js ─ flow designer controller
547
+ urlState.js ─ the URL keys and values for selection and steps (React-free)
548
+ useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
526
549
  useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
527
550
  designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
528
551
  pages.jsx ─ FactoryBuilder, FactoryScreen
@@ -537,6 +560,8 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
537
560
  npm test
538
561
  ```
539
562
 
563
+ `test/urlState.test.js` covers the URL state: the key format, and the three controllers reading their places back from a real `MemoryRouter` and being clicked through in memory, rendered with `react-dom/server`.
564
+
540
565
  ## License
541
566
 
542
567
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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",
@@ -48,7 +48,8 @@
48
48
  "@tanstack/react-table": "^8.0.0",
49
49
  "@xeplr/ui-canvas": "^1.0.2",
50
50
  "@xeplr/ui-table": "^1.0.6",
51
- "react": "^18.0.0 || ^19.0.0"
51
+ "react": "^18.0.0 || ^19.0.0",
52
+ "react-router-dom": "^6.0.0 || ^7.0.0"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@tanstack/react-table": "^8.21.3",
@@ -58,6 +59,10 @@
58
59
  "@xeplr/ui-table": "^1.0.6",
59
60
  "react": "^19.2.4",
60
61
  "react-dom": "^19.2.4",
62
+ "react-router-dom": "^7.18.4",
61
63
  "vite": "^7.3.1"
64
+ },
65
+ "dependencies": {
66
+ "@xeplr/ui-utils": "^1.1.2"
62
67
  }
63
68
  }
package/src/pages.jsx CHANGED
@@ -19,6 +19,7 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
19
19
  * @prop lockedNames field names that are already columns of the table — shown read-only
20
20
  * @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
21
21
  * @prop controls control registry (default: built-ins)
22
+ * @prop urlKey keep the selected control and open steps in the URL (<urlKey>Selected, <urlKey>Steps)
22
23
  */
23
24
  export function FactoryBuilder(props) {
24
25
  const ctrl = useFactoryBuilder(props)
@@ -57,6 +58,7 @@ export function FactoryBuilder(props) {
57
58
  * @prop loadScreen async (id) → document — for screens not in `screens`
58
59
  * @prop controls control registry (default: built-ins)
59
60
  * @prop hooks your FactoryHooks subclass (get / save / delete / actions) — also used by the popup
61
+ * @prop urlKey keep the open step of each stepper in the URL (<urlKey>Steps); never passed to the popup
60
62
  */
61
63
  export function FactoryScreen(props) {
62
64
  const ctrl = useFactoryScreen(props)
@@ -166,6 +168,7 @@ const EMPTY_SCREEN = {
166
168
  * @prop onSave async (flow) → void
167
169
  * @prop onPublish async (key) → void
168
170
  * @prop onChange (flow) → void
171
+ * @prop urlKey keep the selected step in the URL (<urlKey>Selected)
169
172
  */
170
173
  export function FlowBuilder(props) {
171
174
  const ctrl = useFlowBuilder(props)
@@ -0,0 +1,71 @@
1
+ // WHERE YOU ARE LIVES IN THE URL — the pure half, no React.
2
+ //
3
+ // The step a stepper is open at, and the control (or flow step) whose
4
+ // properties are open, are places a person navigates to: a refresh or a
5
+ // shared link must land on them again. This file is how they are named in the
6
+ // URL's query; useUrlPlaces.js reads and writes them.
7
+ //
8
+ // THE KEYS ARE THE HOST'S. A builder, a running screen and the preview inside
9
+ // the builder can share one page, and the host app has keys of its own, so a
10
+ // hook writes the URL only when given a `urlKey`. Without one it keeps the
11
+ // same values in memory, exactly as before.
12
+
13
+ /**
14
+ * The query keys under a host's `urlKey`, or null (in memory).
15
+ * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected' }
16
+ */
17
+ export function factoryUrlKeys(urlKey) {
18
+ if (!urlKey) return null
19
+ return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected` }
20
+ }
21
+
22
+ /**
23
+ * Which step each stepper is open at → one query value. Step 0 is where a
24
+ * stepper starts, so it is left out — a screen on its first steps has a
25
+ * clean URL. Ids are URI-encoded, so one holding ':' or ',' splits back.
26
+ * { s1: 2, s2: 0 } → 's1:2'
27
+ */
28
+ export function encodeSteps(open) {
29
+ return Object.keys(open || {})
30
+ .filter((id) => Number.isInteger(open[id]) && open[id] > 0)
31
+ .map((id) => `${encodeURIComponent(id)}:${open[id]}`)
32
+ .join(',') || null
33
+ }
34
+
35
+ /** The query value back to { stepper id: index }; anything unreadable is dropped. */
36
+ export function decodeSteps(value) {
37
+ const open = {}
38
+ if (!value || typeof value !== 'string') return open
39
+ value.split(',').forEach((pair) => {
40
+ const at = pair.lastIndexOf(':')
41
+ if (at <= 0) return
42
+ if (!/^\d+$/.test(pair.slice(at + 1))) return
43
+ const index = Number(pair.slice(at + 1))
44
+ let id
45
+ try { id = decodeURIComponent(pair.slice(0, at)) } catch { return }
46
+ open[id] = index
47
+ })
48
+ return open
49
+ }
50
+
51
+ /**
52
+ * Selecting adds a history entry when it opens or closes the properties
53
+ * (nothing → something, something → nothing), so Back closes them; moving
54
+ * from one selected thing to another is a switch and replaces the entry.
55
+ */
56
+ export function replacesHistory(previous, next) {
57
+ const had = previous !== null && previous !== undefined && previous !== ''
58
+ const has = next !== null && next !== undefined && next !== ''
59
+ return had && has
60
+ }
61
+
62
+ /**
63
+ * A multi-selection is NOT a place: it is a working set for Delete or
64
+ * Duplicate, not somewhere a person goes. Only a single selection — the one
65
+ * whose properties are open — is named in the URL.
66
+ * one id → that id none, or several → null
67
+ */
68
+ export function selectionPlace(ids) {
69
+ const list = ids ? [...ids] : []
70
+ return list.length === 1 ? list[0] : null
71
+ }
@@ -7,6 +7,8 @@ import {
7
7
  } from './document.js'
8
8
  import { validateDocument } from './validateDocument.js'
9
9
  import { presetFor, presetProps, presetGroup, presetHints, convertField } from './presets.js'
10
+ import { useUrlSteps, useUrlSelection } from './useUrlPlaces.js'
11
+ import { selectionPlace } from './urlState.js'
10
12
 
11
13
  // The builder's controller: the document being edited, what is selected, and
12
14
  // every change the builder can make. No JSX — designs/BuilderSample.jsx draws
@@ -36,10 +38,29 @@ import { presetFor, presetProps, presetGroup, presetHints, convertField } from '
36
38
  * shown); throw with `confirm: [{ column, records }]` when removed fields would drop
37
39
  * columns — the builder asks, and calls again with confirmDrop naming them.
38
40
  * @param controls the control registry (default: the built-ins)
41
+ * @param urlKey where the selected control and the open steps live in the URL:
42
+ * `<urlKey>Selected` and `<urlKey>Steps`, so a refresh or a shared
43
+ * link lands on the same control. Omitted → kept in memory.
39
44
  */
40
- export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800 } = {}) {
45
+ export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
41
46
  const [doc, setDoc] = useState(() => given || createScreen({ name }))
42
- const [selected, setSelected] = useState(() => new Set())
47
+
48
+ // ── selection ─────────────────────────────────────────────────────────
49
+ // ONE selected control is a place — its properties are open — so it is
50
+ // named in the URL (by id, found again in the document). Several selected
51
+ // is a working set for Delete or Duplicate, not a place: it stays here, and
52
+ // the URL then names nothing.
53
+ const [placeId, selectPlace] = useUrlSelection(urlKey)
54
+ const [several, setSeveral] = useState(null)
55
+ const placeNode = placeId === null ? null : doc.nodes.find((n) => String(n.id) === placeId) || null
56
+ const placeNodeId = placeNode ? placeNode.id : null
57
+ const selected = useMemo(() => several || new Set(placeNodeId === null ? [] : [placeNodeId]), [several, placeNodeId])
58
+ const selectedRef = useRef(selected); selectedRef.current = selected
59
+ const setSelected = useCallback((next) => {
60
+ const ids = [...((typeof next === 'function' ? next(selectedRef.current) : next) || [])]
61
+ setSeveral(ids.length > 1 ? new Set(ids) : null)
62
+ selectPlace(selectionPlace(ids))
63
+ }, [selectPlace])
43
64
  const [dirty, setDirty] = useState(false)
44
65
  const [saving, setSaving] = useState(false)
45
66
  const [saveError, setSaveError] = useState(null)
@@ -57,7 +78,10 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
57
78
  if (JSON.stringify(given) === JSON.stringify(docRef.current)) return
58
79
  docRef.current = given
59
80
  setDoc(given)
60
- setSelected(new Set())
81
+ // A multi-selection belongs to the old document. The URL's control is
82
+ // kept: a host that loads the screen after mounting hands it in here, and
83
+ // the link's control is found in it — or, if it is not there, nothing is.
84
+ setSeveral(null)
61
85
  setDirty(false)
62
86
  }, [given])
63
87
 
@@ -104,7 +128,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
104
128
  // Which step of each stepper is open on the canvas. Everything dropped while
105
129
  // a step is open belongs to that step, and the canvas shows only that step —
106
130
  // so one screen holds as many little screens as it has steps.
107
- const [openSteps, setOpenSteps] = useState({})
131
+ const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
108
132
  const stepperNodes = useMemo(() => steppers(doc, controls), [doc, controls])
109
133
  const [building, setBuilding] = useState(null) // the stepper being built
110
134
  const activeStepper = stepperNodes.find((n) => n.id === building) || stepperNodes[stepperNodes.length - 1] || null
@@ -113,7 +137,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
113
137
  const goToStep = useCallback((id, index) => {
114
138
  setBuilding(id)
115
139
  setOpenSteps((o) => ({ ...o, [id]: index }))
116
- }, [])
140
+ }, [setOpenSteps])
117
141
 
118
142
  /** The step a new control lands on: the one open on the stepper being built. */
119
143
  const dropStep = useCallback(() => (
@@ -142,7 +166,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
142
166
  })
143
167
  if (added) setSelected(new Set([added.id]))
144
168
  return added
145
- }, [update, controls, dropStep])
169
+ }, [update, controls, dropStep, setSelected])
146
170
 
147
171
  const moveControl = useCallback((id, patch) => update((d) => moveNode(d, id, patch)), [update])
148
172
 
@@ -164,13 +188,12 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
164
188
  return dropped
165
189
  }, [update, controls])
166
190
 
167
- const selectedRef = useRef(selected); selectedRef.current = selected
168
191
  const removeSelected = useCallback(() => {
169
192
  const ids = [...selectedRef.current]
170
193
  if (!ids.length) return
171
194
  update((d) => removeNodes(d, ids))
172
195
  setSelected(new Set())
173
- }, [update])
196
+ }, [update, setSelected])
174
197
 
175
198
  /** Everything selected moves to a copy, just below the original. */
176
199
  const duplicateSelected = useCallback(() => {
@@ -191,7 +214,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
191
214
  return next
192
215
  })
193
216
  setSelected(new Set(created))
194
- }, [update, controls])
217
+ }, [update, controls, setSelected])
195
218
 
196
219
  // ── validation + save ─────────────────────────────────────────────────
197
220
  const validation = useMemo(() => validateDocument(doc, controls), [doc, controls])
@@ -4,6 +4,7 @@ import { inputNodes, nodesForSteps, steppers, stepsOf, stepOf, resolveStep, reac
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'
7
+ import { useUrlSteps } from './useUrlPlaces.js'
7
8
 
8
9
  // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
9
10
  //
@@ -48,10 +49,12 @@ import { hookMethod } from './hooks.js'
48
49
  * @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
49
50
  * @param controls the control registry (default: the built-ins)
50
51
  * @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
52
+ * @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
53
+ * so a refresh or a shared link lands on the same step. Omitted → in memory.
51
54
  */
52
55
  export function useFactoryScreen({
53
56
  document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
54
- onOpenRecord, onDone, onDirtyChange,
57
+ onOpenRecord, onDone, onDirtyChange, urlKey,
55
58
  controls = CONTROLS
56
59
  } = {}) {
57
60
  const check = useMemo(() => validateDocument(doc, controls), [doc, controls])
@@ -161,7 +164,7 @@ export function useFactoryScreen({
161
164
  // A stepper shows one step at a time. Which step each is open at lives here;
162
165
  // the fields of the steps not showing are still filled in, still checked and
163
166
  // still saved — a step is what the person sees, not a separate form.
164
- const [openSteps, setOpenSteps] = useState({})
167
+ const [openSteps, setOpenSteps] = useUrlSteps(urlKey)
165
168
  // Steps the app has switched off — { stepper id: [index, …] }. A screen may
166
169
  // have branches; which of them apply is the app's to say, from its hooks.
167
170
  const [disabledSteps, setDisabledSteps] = useState({})
@@ -169,20 +172,20 @@ export function useFactoryScreen({
169
172
  const visibleNodes = useMemo(() => (check.ok ? nodesForSteps(doc, openSteps, controls) : []), [check.ok, doc, openSteps, controls])
170
173
 
171
174
  // A screen whose steps changed under it (a field moved to another step, a
172
- // step removed) must not stay open at a step that is gone.
175
+ // step removed) must not stay open at a step that is gone. Steppers this
176
+ // document does not have are left alone: a screen still loading has none,
177
+ // and dropping them would lose the step a link was opened at.
173
178
  useEffect(() => {
174
179
  setOpenSteps((open) => {
175
- let changed = false
176
- const next = {}
180
+ const next = { ...open }
177
181
  stepperNodes.forEach((n) => {
178
182
  const count = stepsOf(n).length
179
183
  const at = Number.isInteger(open[n.id]) ? open[n.id] : 0
180
184
  next[n.id] = Math.min(Math.max(at, 0), Math.max(count - 1, 0))
181
- if (next[n.id] !== open[n.id]) changed = true
182
185
  })
183
- return changed || Object.keys(next).length !== Object.keys(open).length ? next : open
186
+ return next
184
187
  })
185
- }, [stepperNodes])
188
+ }, [stepperNodes, setOpenSteps])
186
189
 
187
190
  const stepControl = useMemo(() => {
188
191
  const at = (id) => (Number.isInteger(openSteps[id]) ? openSteps[id] : 0)
@@ -274,7 +277,7 @@ export function useFactoryScreen({
274
277
  })
275
278
  }
276
279
  return control
277
- }, [stepperNodes, openSteps, disabledSteps, doc, controls])
280
+ }, [stepperNodes, openSteps, setOpenSteps, disabledSteps, doc, controls])
278
281
 
279
282
  live.current.steps = stepControl
280
283
 
@@ -4,6 +4,7 @@ import {
4
4
  addTransition, setTransition, removeTransition, validateFlow, firstStep, layoutFlow, FLOW_END
5
5
  } from './flow.js'
6
6
  import { inputNodes } from './document.js'
7
+ import { useUrlSelection } from './useUrlPlaces.js'
7
8
 
8
9
  // DESIGNING a flow: which screens, in what order, and what decides the order.
9
10
  //
@@ -23,10 +24,12 @@ export const FLOW_AUTOSAVE_DELAY = 800
23
24
  * @param onSave async (flow) → void — called after edits, only with a flow that makes sense
24
25
  * @param onPublish async (key) → void — shows a Publish button
25
26
  * @param onChange (flow) → void — every edit
27
+ * @param urlKey where the selected step lives in the URL (`<urlKey>Selected`), so a
28
+ * refresh or a shared link opens the same step. Omitted → in memory.
26
29
  */
27
- export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange } = {}) {
30
+ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, onSave, onPublish, onChange, urlKey } = {}) {
28
31
  const [flow, setFlow] = useState(() => given || createFlow({ name }))
29
- const [selected, setSelected] = useState(null) // a step key
32
+ const [selected, setSelected] = useUrlSelection(urlKey) // a step key
30
33
  const [dirty, setDirty] = useState(false)
31
34
  const [saving, setSaving] = useState(false)
32
35
  const [saveError, setSaveError] = useState(null)
@@ -102,13 +105,13 @@ export function useFlowBuilder({ flow: given, name, screens = [], loadScreen, on
102
105
  })
103
106
  if (added) setSelected(added.stepKey)
104
107
  return added
105
- }, [update])
108
+ }, [update, setSelected])
106
109
  const move = useCallback((stepKey, at) => update((f) => moveStep(f, stepKey, at)), [update])
107
110
  const change = useCallback((stepKey, patch) => update((f) => setStep(f, stepKey, patch)), [update])
108
111
  const remove = useCallback((stepKey) => {
109
112
  update((f) => removeStep(f, stepKey))
110
113
  setSelected((s) => (s === stepKey ? null : s))
111
- }, [update])
114
+ }, [update, setSelected])
112
115
  const connect = useCallback((fromKey, target, when = null) => update((f) => addTransition(f, fromKey, target, when)), [update])
113
116
  const changeTransition = useCallback((fromKey, index, patch) => update((f) => setTransition(f, fromKey, index, patch)), [update])
114
117
  const disconnect = useCallback((fromKey, index) => update((f) => removeTransition(f, fromKey, index)), [update])
@@ -0,0 +1,48 @@
1
+ import { useCallback, useMemo, useRef } from 'react'
2
+ import { useUrlState } from '@xeplr/ui-utils/src/url/useUrlState.js'
3
+ import { factoryUrlKeys, encodeSteps, decodeSteps, replacesHistory } from './urlState.js'
4
+
5
+ // The two places the factory's controllers keep, each in useState's shape so
6
+ // a controller swaps one line. In the URL under the host's `urlKey`; without
7
+ // one, in memory with the same rules (see urlState.js).
8
+
9
+ /**
10
+ * [openSteps, setOpenSteps] — which step each stepper is open at,
11
+ * { stepper id: index }. Moving between steps is a switch, so it replaces the
12
+ * history entry rather than adding one.
13
+ */
14
+ export function useUrlSteps(urlKey) {
15
+ const keys = factoryUrlKeys(urlKey)
16
+ const [value, setValue] = useUrlState(keys ? keys.steps : 'steps', { inUrl: Boolean(keys), replace: true })
17
+ const open = useMemo(() => decodeSteps(value), [value])
18
+ // Two moves in one event build on each other, as useState's updater would.
19
+ const openRef = useRef(open); openRef.current = open
20
+ const setOpen = useCallback((next) => {
21
+ const resolved = typeof next === 'function' ? next(openRef.current) : next
22
+ const encoded = encodeSteps(resolved)
23
+ if (encoded === encodeSteps(openRef.current)) return
24
+ openRef.current = decodeSteps(encoded)
25
+ setValue(encoded)
26
+ }, [setValue])
27
+ return [open, setOpen]
28
+ }
29
+
30
+ /**
31
+ * [selected, setSelected] — the one thing whose properties are open (a
32
+ * control's id, a flow step's key), or null. Opening or closing the
33
+ * properties adds a history entry; moving to another thing replaces it.
34
+ * Read back from the URL it is a string.
35
+ */
36
+ export function useUrlSelection(urlKey) {
37
+ const keys = factoryUrlKeys(urlKey)
38
+ const [value, setValue] = useUrlState(keys ? keys.selected : 'selected', { inUrl: Boolean(keys) })
39
+ const valueRef = useRef(value); valueRef.current = value
40
+ const select = useCallback((next) => {
41
+ const resolved = typeof next === 'function' ? next(valueRef.current) : next
42
+ const v = resolved === undefined || resolved === '' ? null : resolved
43
+ if ((v === null ? null : String(v)) === valueRef.current) return
44
+ setValue(v, { replace: replacesHistory(valueRef.current, v) })
45
+ valueRef.current = v === null ? null : String(v)
46
+ }, [setValue])
47
+ return [value, select]
48
+ }