@xeplr/ui-factory 1.0.13 → 1.1.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/AUTHORING.md +29 -5
- package/README.md +140 -18
- package/package.json +1 -1
- package/src/controls.js +19 -13
- package/src/designs/BuilderSample.jsx +77 -19
- package/src/designs/ControlView.jsx +10 -1
- package/src/designs/FlowRunnerSample.jsx +6 -4
- package/src/designs/ListView.jsx +40 -21
- package/src/designs/Palette.jsx +2 -1
- package/src/designs/PropertyPanel.jsx +57 -1
- package/src/designs/ScreenEditorSample.jsx +97 -0
- package/src/designs/ScreenModal.jsx +2 -2
- package/src/designs/ScreenSample.jsx +100 -41
- package/src/designs/factory.css +170 -76
- package/src/designs/index.js +4 -0
- package/src/designs/styles.js +11 -1
- package/src/generate.js +35 -10
- package/src/hooks.js +1 -1
- package/src/index.js +6 -2
- package/src/model.js +2 -1
- package/src/pages.jsx +15 -3
- package/src/presets.js +324 -0
- package/src/remote.js +7 -1
- package/src/scaffold.js +2 -2
- package/src/screenSource.js +91 -0
- package/src/tableSchema.js +109 -6
- package/src/useFactoryBuilder.js +65 -16
- package/src/useFactoryScreen.js +74 -39
- package/src/useScreenSource.js +81 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
package/AUTHORING.md
CHANGED
|
@@ -60,7 +60,7 @@ A request for "a form for employees" is an **entity**. Every entity is the same
|
|
|
60
60
|
```jsx
|
|
61
61
|
export class EmployeeHooks extends FactoryHooks {
|
|
62
62
|
get(ctx) { return super.get(ctx) } // a list's rows (ctx.many), or the record Edit opens (ctx.id)
|
|
63
|
-
save(values, ctx) { return super.save(values, ctx) } //
|
|
63
|
+
save(values, ctx) { return super.save(values, ctx) } // the Save button; returns the saved record
|
|
64
64
|
delete(record, ctx) { return super.delete(record, ctx) } // a list row, after the person confirmed
|
|
65
65
|
actions(ctx) { return super.actions(ctx) } // extra row buttons: [{ label, onClick: (record, ctx) => … }]
|
|
66
66
|
}
|
|
@@ -92,7 +92,7 @@ A request for "a form for employees" is an **entity**. Every entity is the same
|
|
|
92
92
|
8. **Server hooks, if the request needs them.** Anything beyond storing what the form shows — a value worked out on save, a check against other records, an email afterwards, a list narrowed to the user — goes in `employee.hooks.js`, never in the screen: `save`, `get` (list and one record) and `delete`, each with `before`, `after`, `error` and `override`. Register it with `factory.init({ hooks: { employee_edit: require('./employee.hooks') } })`. `override` replaces the whole operation — rules and the other hooks included — so use it only when the generic save / get / delete does not apply at all. The `screens` command never overwrites an existing hooks file, even with `--force`. Details: the [`@xeplr/factory` README](https://www.npmjs.com/package/@xeplr/factory#hooks).
|
|
93
93
|
9. **Hand over.** The person opens the screens in the designer to move, resize and restyle controls.
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Do **not** add a button control for saving: every screen's footer already has **Save** (an AJAX call — never a form submit) and **Cancel** where it can go back. If the request mentions seeing or editing what was entered ("…and show the employees below"), add a `list`.
|
|
96
96
|
|
|
97
97
|
Changing an existing screen: edit the document's `props` directly (labels, validation, options), validate, and hand it back. Regenerate from a spec only when the layout should start over.
|
|
98
98
|
|
|
@@ -192,7 +192,7 @@ Each field:
|
|
|
192
192
|
| `text` | `minLength`, `maxLength`, `pattern` (a regex string), `patternMessage` (shown when it fails) |
|
|
193
193
|
| `textarea` | `minLength`, `maxLength` |
|
|
194
194
|
| `number` | `min`, `max`, `integer` |
|
|
195
|
-
| `date` | `min`, `max` — as `"YYYY-MM-DD"` |
|
|
195
|
+
| `date` | `min`, `max` — as `"YYYY-MM-DD"`; `notFuture: true` (a date of birth) |
|
|
196
196
|
| `datetime` | `min`, `max` — as `"YYYY-MM-DDTHH:MM"` |
|
|
197
197
|
| `multiselect` | `minItems`, `maxItems` — how many may be chosen |
|
|
198
198
|
| `checkbox`, `dropdown`, `radio`, `file` | none (use `required`) |
|
|
@@ -237,11 +237,35 @@ A dropdown **always saves an `id` and shows a `name`.**
|
|
|
237
237
|
|
|
238
238
|
## Choosing controls
|
|
239
239
|
|
|
240
|
+
**Ready-made fields first.** For these, give the `type` as the ready-made
|
|
241
|
+
field's key and add nothing else unless the request asks for it — the keyboard,
|
|
242
|
+
pattern, message, limits and options come with it:
|
|
243
|
+
|
|
244
|
+
| the request says | `type` |
|
|
245
|
+
|---|---|
|
|
246
|
+
| email | `email` |
|
|
247
|
+
| phone, mobile | `phone` |
|
|
248
|
+
| website, URL, link | `url` |
|
|
249
|
+
| LinkedIn profile | `linkedin` |
|
|
250
|
+
| age | `age` (whole number, 0–130) |
|
|
251
|
+
| date of birth | `dateOfBirth` (never in the future) |
|
|
252
|
+
| gender | `gender` (Male, Female, Others) |
|
|
253
|
+
| country, nationality | `country` (ISO codes, shown by name) |
|
|
254
|
+
| amount, price, salary, fee | `amount` (0 or more) |
|
|
255
|
+
| percentage | `percentage` (0–100) |
|
|
256
|
+
| a yes/no question to answer explicitly | `yesNo` |
|
|
257
|
+
| postal code, ZIP, PIN | `postalCode` |
|
|
258
|
+
|
|
259
|
+
`{ "label": "Work email", "type": "email", "required": true }` keeps the label
|
|
260
|
+
and adds the rest. A field with **no type** whose label reads like one of these
|
|
261
|
+
gets it anyway (`{ "label": "Age" }` is the Age field); `"type": "text"` opts out.
|
|
262
|
+
Rules you give merge over the ready-made ones (`"validation": { "max": 120 }`).
|
|
263
|
+
|
|
240
264
|
| the request says | use |
|
|
241
265
|
|---|---|
|
|
242
|
-
| a name, title,
|
|
266
|
+
| a name, title, code | `text` (add a `pattern` for a code format) |
|
|
243
267
|
| notes, description, address, comments | `textarea` |
|
|
244
|
-
|
|
|
268
|
+
| quantity, score, count | `number` (`integer: true` for counts) |
|
|
245
269
|
| a date — birth, start, due | `date` |
|
|
246
270
|
| an appointment, a deadline with a time, when something happened | `datetime` |
|
|
247
271
|
| yes/no, agree, active, remote | `checkbox` |
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @xeplr/ui-factory
|
|
2
2
|
|
|
3
|
-
**Design data-entry screens, save them as JSON, run them as working forms.** Drag controls onto a canvas, set their labels, validation, options, fonts and colours. The same document renders as a live form in your app
|
|
3
|
+
**Design data-entry screens, save them as JSON, run them as working forms.** Drag controls onto a canvas, set their labels, validation, options, fonts and colours. The same document renders as a live form in your app, saved by its **Save** button over AJAX, with a list of the saved records to open, edit or delete.
|
|
4
4
|
|
|
5
5
|
It is built to work with Claude: ask for *"a form for employees with name, email, department and start date"*, and Claude creates **two screens** — an employee **list** and an **add / edit** form that the list's Edit and New open in a popup — plus the two pages that show them. A person then refines either one in the designer.
|
|
6
6
|
|
|
@@ -57,7 +57,7 @@ import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
|
57
57
|
<div style={{ height: '100vh' }}>
|
|
58
58
|
<FactoryBuilder
|
|
59
59
|
document={screen} // omit to start a new one
|
|
60
|
-
onSave={async (doc) => api.saveScreen(doc)} // your storage — called
|
|
60
|
+
onSave={async (doc) => api.saveScreen(doc)} // your storage — called when Save is pressed
|
|
61
61
|
listTables={async () => api.listTables()} // for "Saves to", lists and table dropdowns
|
|
62
62
|
fetchOptions={async ({ table }) => api.options(table)} // Preview with real options
|
|
63
63
|
fetchRecords={async ({ source }) => api.records(source)} // Preview with real records
|
|
@@ -70,6 +70,21 @@ import { FactoryBuilder } from '@xeplr/ui-factory'
|
|
|
70
70
|
- Select a control to set its label, field name, placeholder, required, default, validation, and — for a dropdown — where its options come from.
|
|
71
71
|
- **Every look is a property:** font, size, weight, italic, alignment, text colour, background, border colour/width, corner radius — and for an input, its label's size, weight and colour.
|
|
72
72
|
- With nothing selected, the panel shows the **screen**: the table it saves to, its width, and the font and colours every control inherits.
|
|
73
|
+
|
|
74
|
+
**Nothing saves itself — here, or anywhere else in this package.** The bar says
|
|
75
|
+
*Unsaved changes* and offers **Save**; that is the only thing that writes the
|
|
76
|
+
draft, so trying a layout out and walking away leaves the saved screen as it
|
|
77
|
+
was. Closing the tab with unsaved work asks first.
|
|
78
|
+
|
|
79
|
+
It used to save 800ms after every edit, and that was wrong twice over: there
|
|
80
|
+
was no such thing as trying something out, and no moment at which anybody said
|
|
81
|
+
"this is right" — and a field is invalid *while it is being built*, so the
|
|
82
|
+
autosave met half-made work and reported it as an error nobody asked for.
|
|
83
|
+
`autosaveDelay` is still accepted and ignored, so a host passing it is not
|
|
84
|
+
broken.
|
|
85
|
+
|
|
86
|
+
**Publish still saves first**, because publishing what is on screen is the one
|
|
87
|
+
thing it must never get wrong.
|
|
73
88
|
- **No Save button.** Edits are saved (as a draft) a moment after you stop, whenever the screen is valid; problems are marked on the controls that have them until they are fixed.
|
|
74
89
|
- **Publish** (when `onPublish` is given) makes the draft the version everyone sees and changes its table to match. If removed fields would drop columns, it asks first — "Publishing removes `phone` — 1,240 saved values" — with **Remove and publish** / **Cancel**. Unsafe changes (narrowing a field) are refused with the reason.
|
|
75
90
|
- **`lockedNames`**: fields that are already columns of the table. Their names are read-only, because renaming one would leave its data behind.
|
|
@@ -92,7 +107,11 @@ import { FactoryScreen } from '@xeplr/ui-factory'
|
|
|
92
107
|
/>
|
|
93
108
|
```
|
|
94
109
|
|
|
95
|
-
**
|
|
110
|
+
**Save is a button and an AJAX call — never a form submit.** Nothing is written while the person types: the footer says "Unsaved changes" until they press **Save**, which makes one call to your `onSave`. There is no `<form>` post and Enter saves nothing. If something is missing, Save marks every field that needs attention instead of sending. The first save of a new record creates it: return the saved record and its `id` makes every later save an update.
|
|
111
|
+
|
|
112
|
+
- With `onDone` (a page, or the list's popup), the footer has **Cancel** and **Save**. Save saves and then calls `onDone`. Cancel calls `onDone` without saving, asking first if anything is unsaved. Without `onDone` (a form embedded in a page) there is Save alone, and it shows "Saved".
|
|
113
|
+
- Leaving with unsaved changes asks first: Cancel, the popup's ×/Escape, a list-on-the-form opening another record, and closing or reloading the tab. **In-app navigation (a side-rail click) is the host router's to guard** — `ctrl.dirty` / `onDirtyChange(dirty)` say when there is something to lose.
|
|
114
|
+
- `useFactoryScreen` returns `save()` (the button), `dirty`, `confirmDiscard()` and `flush()` (save only if unsaved — what a flow's Next does). `AUTOSAVE_DELAY` and `autosaveDelay` are gone.
|
|
96
115
|
|
|
97
116
|
If `onSave` throws an error with `fields: [{ field, message }]` (what `createFactoryApi` does with the server's 422 — a rule, or a hook's `reject`), each message shows on its field until that field is changed.
|
|
98
117
|
|
|
@@ -143,7 +162,7 @@ const taskHooks = new TaskHooks()
|
|
|
143
162
|
| method | default | ctx |
|
|
144
163
|
|---|---|---|
|
|
145
164
|
| `get(ctx)` | `fetchRecords` for a list, `fetchRecord` for the record Edit opens | `many`, `id`, `screen`, `source` |
|
|
146
|
-
| `save(values, ctx)` | `onSave` —
|
|
165
|
+
| `save(values, ctx)` | `onSave` — when Save is pressed; return the saved record | `id`, `isNew`, `screen`, `source` |
|
|
147
166
|
| `delete(record, ctx)` | `onDelete`, after the person confirmed | `id`, `screen`, `source` |
|
|
148
167
|
| `actions(ctx)` | `[]` — extra row buttons `{ label, onClick(record, ctx) }` beside Edit / Delete | `screen`, `refresh()` |
|
|
149
168
|
|
|
@@ -153,7 +172,7 @@ Rules that must hold belong in the server's hooks; the browser only shapes what
|
|
|
153
172
|
|
|
154
173
|
### Lists of saved records
|
|
155
174
|
|
|
156
|
-
A **List** control shows the records of the table the screen saves to (or another), in `@xeplr/ui-table`, with the columns you tick. Its **Edit in** names the edit screen: **Edit** and **New** open that screen in a popup
|
|
175
|
+
A **List** control shows the records of the table the screen saves to (or another), in `@xeplr/ui-table`, with the columns you tick. Its **Edit in** names the edit screen: **Edit** and **New** open that screen in a popup: its Save saves, closes the popup and refreshes the list; Cancel closes it unsaved.
|
|
157
176
|
|
|
158
177
|
**Opens in: a page of its own** sends them to a page in your app instead. The factory has no router, so it asks — `onOpenRecord` on the list's screen, and `onDone` on the page, which draws a **Done** button that saves what is pending and calls it:
|
|
159
178
|
|
|
@@ -178,6 +197,47 @@ import schemaHandler from '@xeplr/schema-handler' // CommonJS: default
|
|
|
178
197
|
const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throws ValidationError listing each field
|
|
179
198
|
```
|
|
180
199
|
|
|
200
|
+
## Your screens, inside someone else's designer
|
|
201
|
+
|
|
202
|
+
`@xeplr/ui-workflow`'s flow designer has a **Screen** step, and it asks its
|
|
203
|
+
host two things: which screens exist and what each one hands back, and how to
|
|
204
|
+
design one without leaving the flow. It cannot answer either itself — it
|
|
205
|
+
carries a screen key and knows nothing else about forms, on purpose, so an app
|
|
206
|
+
with a different screen system is not locked out.
|
|
207
|
+
|
|
208
|
+
This package answers both, in one hook:
|
|
209
|
+
|
|
210
|
+
```jsx
|
|
211
|
+
import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'
|
|
212
|
+
|
|
213
|
+
const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
|
|
214
|
+
<WorkflowDesigner screens={screens} screenEditor={screenEditor} … />
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **`screens`** is `[{ key, name, fields }]`. `fields` comes from
|
|
218
|
+
`inputNodes(doc)` — this package's own answer to "which nodes hold a value"
|
|
219
|
+
— so a label or a stepper is never offered as a field, and a control type
|
|
220
|
+
added here reaches every app without one of them being edited. Names arrive
|
|
221
|
+
first and fields follow, so the menu is usable while documents load; a
|
|
222
|
+
document that will not load leaves its screen choosable without fields.
|
|
223
|
+
- **`screenEditor`** is the body of the designer's modal: the builder, on the
|
|
224
|
+
form that step shows, or on a new one it makes (`screenKey: null` → name it
|
|
225
|
+
→ `createEntity`). **Publishing** is what fires `onDone({ key, name, fields })`,
|
|
226
|
+
because a draft changes nothing anybody can run — and that is what tells the
|
|
227
|
+
flow the form's new shape, so a field added there appears in every step that
|
|
228
|
+
shows that form.
|
|
229
|
+
|
|
230
|
+
**Why this is a package and not four lines in your app.** It used to be the
|
|
231
|
+
latter, and that is a bug: an app is generated once and then lives its own
|
|
232
|
+
life, so glue copied into it can never be fixed centrally. An app scaffolded
|
|
233
|
+
last month would keep the old version forever, and a control added here would
|
|
234
|
+
never reach it. Anything every app would write identically belongs on this
|
|
235
|
+
side of the line — `@xeplr/cli`'s generated `FlowDesigner.jsx` is two props,
|
|
236
|
+
and its test suite fails if the glue reappears in the app.
|
|
237
|
+
|
|
238
|
+
The pure half is `screenSource.js` — `describeScreen`, `loadScreens`,
|
|
239
|
+
`keyFromName`, `editScreenOf` — testable with no React and no server.
|
|
240
|
+
|
|
181
241
|
## Flows — screens, one after another
|
|
182
242
|
|
|
183
243
|
A screen is a form; a **flow** is the journey across several of them. Design it
|
|
@@ -207,7 +267,7 @@ that does — Xeplr Workflow — so a run remembers where it got to: a journey c
|
|
|
207
267
|
be left and picked up later, by someone else, on another day. Without such a
|
|
208
268
|
service there are no flows; the factory has no engine of its own, on purpose.
|
|
209
269
|
|
|
210
|
-
**Each screen still saves
|
|
270
|
+
**Each screen still saves into its own table** — the flow's **Next** saves what is unsaved (one AJAX call) and then moves on. What travels with the
|
|
211
271
|
run is only what the arrows need and the id of the row that was written
|
|
212
272
|
(`recordId`), so the data is exactly where it would be without a flow.
|
|
213
273
|
|
|
@@ -231,10 +291,10 @@ Both walk someone through something, and they answer different questions:
|
|
|
231
291
|
|
|
232
292
|
| type | value | validation |
|
|
233
293
|
|---|---|---|
|
|
234
|
-
| `text` | string | `minLength`, `maxLength`, `pattern`, `patternMessage` |
|
|
294
|
+
| `text` | string | `minLength`, `maxLength`, `pattern`, `patternMessage` — and `inputType` (`text`, `email`, `tel`, `url`): the phone keyboard and autofill |
|
|
235
295
|
| `textarea` | string | `minLength`, `maxLength` |
|
|
236
296
|
| `number` | number | `min`, `max`, `integer` |
|
|
237
|
-
| `date` | `"YYYY-MM-DD"` | `min`, `max` |
|
|
297
|
+
| `date` | `"YYYY-MM-DD"` | `min`, `max`, `notFuture` (on or before today, whenever it is filled in) |
|
|
238
298
|
| `datetime` | `"YYYY-MM-DDTHH:MM"` | `min`, `max` |
|
|
239
299
|
| `checkbox` | boolean | `required` = must be ticked |
|
|
240
300
|
| `dropdown` | option id | — |
|
|
@@ -245,10 +305,68 @@ Both walk someone through something, and they answer different questions:
|
|
|
245
305
|
| `label` | — | text with a heading, subheading or text preset |
|
|
246
306
|
| `list` | — | saved records, with New / Edit / Delete |
|
|
247
307
|
|
|
248
|
-
A screen is a **sheet
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
308
|
+
A screen is a **sheet**, centred in the space it is given and shown at its
|
|
309
|
+
design width: an elevated card in the app's theme (`--xeplr-bg-tertiary`, with
|
|
310
|
+
`--xeplr-border-primary` round it) — white in a light app, the lighter grey in
|
|
311
|
+
a dark one, with inputs in the theme's input colours. A screen whose style sets
|
|
312
|
+
its own `color` / `background` keeps them everywhere, input boxes included, so
|
|
313
|
+
dark ink on paper is never shown on a dark theme's input. Set
|
|
314
|
+
`--xeplr-factory-paper` on the host to change every sheet. A screen with no
|
|
315
|
+
fields (only lists) draws **no** sheet — the list is its own card.
|
|
316
|
+
|
|
317
|
+
Under the fields sits **one footer**, inset to line up with them: Back and
|
|
318
|
+
Next, then "Step 2 of 3" with the save status under it ("Unsaved changes",
|
|
319
|
+
"Saved"), then Cancel and Save. A screen without steps has the status, Cancel
|
|
320
|
+
and Save alone.
|
|
321
|
+
|
|
322
|
+
A **list** is a single card, as tall as its rows: the title and + New sit in
|
|
323
|
+
the table's own toolbar, paging appears only when there is more than one page,
|
|
324
|
+
and "Nothing saved yet." shows under the title when there are no rows.
|
|
325
|
+
|
|
326
|
+
### Ready-made fields
|
|
327
|
+
|
|
328
|
+
Twelve fields nobody should have to design twice, in the builder's palette under
|
|
329
|
+
**Common fields**: Email, Phone, LinkedIn profile, Website, Age, Date of birth,
|
|
330
|
+
Gender (Male, Female, Others), Country, Amount, Percentage, Yes / No, Postal code.
|
|
331
|
+
|
|
332
|
+
| field | made of | what it sets |
|
|
333
|
+
|---|---|---|
|
|
334
|
+
| Email / Phone / Website / LinkedIn profile | `text` | `inputType` (email, tel, url keyboards), a `pattern` with a plain-English message, a placeholder, a `maxLength` |
|
|
335
|
+
| Postal code | `text` | letters, digits, spaces and `-`, up to 10 |
|
|
336
|
+
| Age | `number` | a whole number, 0–130 |
|
|
337
|
+
| Amount / Percentage | `number` | 0 or more / 0–100 |
|
|
338
|
+
| Date of birth | `date` | `notFuture` |
|
|
339
|
+
| Gender / Yes / No | `radio`, side by side | its options |
|
|
340
|
+
| Country | `dropdown` | every country — stored as its ISO code (`IN`), shown by name |
|
|
341
|
+
|
|
342
|
+
A ready-made field is **not a new control**: its settings are copied into an
|
|
343
|
+
ordinary one, where they stay editable, and `props.preset` remembers where it
|
|
344
|
+
came from. Changing the catalogue never changes a form already made with it.
|
|
345
|
+
The rules are the field's own, so the server enforces them like any others.
|
|
346
|
+
|
|
347
|
+
**Any field can change kind at any time** — the panel's **Field type**, at the
|
|
348
|
+
top, lists the controls and the ready-made fields. The label, name, position,
|
|
349
|
+
step and required are kept; whatever does not carry over is said ("Not carried
|
|
350
|
+
over: the pattern"). A name that is already a column is never changed.
|
|
351
|
+
|
|
352
|
+
**Forgot?** A plain field whose label reads like a ready-made one ("LinkedIn
|
|
353
|
+
URL" as a text box) gets an offer in its panel — **Make it LinkedIn profile** /
|
|
354
|
+
**Keep as is** — and the builder's bar counts them ("1 suggestion"). They never
|
|
355
|
+
stop a save or a publish; **Keep as is** sets `preset: false` and stops asking.
|
|
356
|
+
|
|
357
|
+
**When the column has to follow.** A change of kind that stays text (text →
|
|
358
|
+
LinkedIn) publishes as usual. One that changes the column's kind (text → Age,
|
|
359
|
+
a number) is a **conversion**: publishing first checks every saved value, across
|
|
360
|
+
every company. If any will not fit, nothing changes and the publish says which
|
|
361
|
+
("`age` has 1 saved value that cannot become integer ("forty")"). If all fit,
|
|
362
|
+
it asks — **Convert and publish** — and then converts, blanks becoming empty.
|
|
363
|
+
A timestamp → date, or a dropdown moved to another table, is still refused.
|
|
364
|
+
|
|
365
|
+
In code: `FIELD_PRESETS`, `presetFor`, `presetProps`, `suggestPreset(label)`,
|
|
366
|
+
`presetHints(doc)`, `convertField(doc, id, target, controls, { lockedNames })`,
|
|
367
|
+
and `conversionFor(table, from, to)` / `planTableChange(…, { confirmConvert })`.
|
|
368
|
+
The builder hook adds `convertControl(id, target)` and `hints`; `publish(confirmDrop,
|
|
369
|
+
confirmConvert)`.
|
|
252
370
|
|
|
253
371
|
Every control also takes `style` — see [AUTHORING.md](./AUTHORING.md#styles) for the keys. Sizes are pixels at the screen's design `width` (default 800): a screen is shown at that size, never stretched, and scaled down only on a narrower display.
|
|
254
372
|
|
|
@@ -304,8 +422,7 @@ same for a screen with more than one (`ctx.steps.disable('approval', stepperId)`
|
|
|
304
422
|
learns which branch it is on. "Step 2 of 4" counts only the steps that apply.
|
|
305
423
|
|
|
306
424
|
**A step is not a separate form.** Every field is a column, is checked, and is
|
|
307
|
-
saved, whichever step is showing — a
|
|
308
|
-
in. **Next** holds at a step whose own fields are not filled in, and says which,
|
|
425
|
+
saved, whichever step is showing — Save can be pressed on any step. **Next** holds at a step whose own fields are not filled in, and says which,
|
|
309
426
|
rather than moving on and leaving a problem behind where nobody is looking.
|
|
310
427
|
|
|
311
428
|
### Options: dropdown, radio group, multi-select
|
|
@@ -377,7 +494,9 @@ Layout is **proportional**, like `@xeplr/ui-canvas` dashboards: `x` and `w` are
|
|
|
377
494
|
|
|
378
495
|
## Theming
|
|
379
496
|
|
|
380
|
-
Styles are namespaced `.xeplr-factory-*` and read
|
|
497
|
+
Styles are namespaced `.xeplr-factory-*` and read `@xeplr/ui-account`'s theme variables (theme.css), each with a light fallback: `--xeplr-accent`, `--xeplr-accent-text`, `--xeplr-text-primary`, `--xeplr-text-secondary`, `--xeplr-border-primary`, `--xeplr-bg-primary` (the builder's plane), `--xeplr-bg-tertiary` (sheets, panels, popups), `--xeplr-bg-hover`, `--xeplr-bg-overlay`, `--xeplr-input-bg`, `--xeplr-input-border`, `--xeplr-success`, `--xeplr-warning`, `--xeplr-danger`. The screen, builder and popup follow the app's light or dark theme with the @xeplr/ui-table inside them — never a white sheet in a dark app.
|
|
498
|
+
|
|
499
|
+
Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.
|
|
381
500
|
|
|
382
501
|
## Try it
|
|
383
502
|
|
|
@@ -393,16 +512,19 @@ src/
|
|
|
393
512
|
controls.js ─ the control registry: palette, property panel contract, rules
|
|
394
513
|
document.js ─ create and edit screen documents
|
|
395
514
|
validateDocument.js ─ the document checker
|
|
396
|
-
values.js ─ values, validation,
|
|
515
|
+
values.js ─ values, validation, whether Save can go out, records and list columns, formSchema
|
|
397
516
|
generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
|
|
398
517
|
scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
|
|
399
518
|
hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
|
|
400
|
-
|
|
519
|
+
presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
|
|
520
|
+
tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
|
|
401
521
|
remote.js ─ createFactoryApi({ fetch }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
|
|
522
|
+
screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
|
|
402
523
|
model.js ─ everything above, React-free
|
|
403
524
|
useFactoryBuilder.js ─ builder controller
|
|
404
525
|
useFactoryScreen.js ─ screen controller
|
|
405
|
-
|
|
526
|
+
useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
|
|
527
|
+
designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
|
|
406
528
|
pages.jsx ─ FactoryBuilder, FactoryScreen
|
|
407
529
|
bin/xeplr-factory.js ─ screens / generate / validate / controls / schema
|
|
408
530
|
examples/ ─ employee.entity.json and what `screens` makes of it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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",
|
package/src/controls.js
CHANGED
|
@@ -164,11 +164,14 @@ export const CONTROLS = {
|
|
|
164
164
|
valueType: 'string',
|
|
165
165
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
166
166
|
defaults: { label: 'Text' },
|
|
167
|
-
|
|
167
|
+
// inputType: the box's keyboard and autofill — text, email, tel, url.
|
|
168
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'inputType', 'style'],
|
|
168
169
|
validation: ['minLength', 'maxLength', 'pattern', 'patternMessage'],
|
|
169
170
|
styles: INPUT_STYLES,
|
|
170
171
|
properties: [
|
|
171
|
-
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }
|
|
172
|
+
FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }, { path: 'props.inputType', label: 'Keyboard', type: 'select', options: [
|
|
173
|
+
{ value: 'text', label: 'Text' }, { value: 'email', label: 'Email' }, { value: 'tel', label: 'Phone' }, { value: 'url', label: 'Web address' }
|
|
174
|
+
], help: 'What a phone shows to type with, and what autofill offers' }]),
|
|
172
175
|
{
|
|
173
176
|
key: 'validation',
|
|
174
177
|
title: 'Validation',
|
|
@@ -191,7 +194,7 @@ export const CONTROLS = {
|
|
|
191
194
|
valueType: 'string',
|
|
192
195
|
defaultSize: { w: 0.92, h: 0.18 },
|
|
193
196
|
defaults: { label: 'Notes' },
|
|
194
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
197
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
195
198
|
validation: ['minLength', 'maxLength'],
|
|
196
199
|
styles: INPUT_STYLES,
|
|
197
200
|
properties: [
|
|
@@ -216,7 +219,7 @@ export const CONTROLS = {
|
|
|
216
219
|
valueType: 'number',
|
|
217
220
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
218
221
|
defaults: { label: 'Number' },
|
|
219
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
222
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'validation', 'style'],
|
|
220
223
|
validation: ['min', 'max', 'integer'],
|
|
221
224
|
styles: INPUT_STYLES,
|
|
222
225
|
properties: [
|
|
@@ -242,8 +245,10 @@ export const CONTROLS = {
|
|
|
242
245
|
valueType: 'date',
|
|
243
246
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
244
247
|
defaults: { label: 'Date' },
|
|
245
|
-
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
246
|
-
|
|
248
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'validation', 'style'],
|
|
249
|
+
// notFuture: on or before TODAY, whenever the form is filled in (a date of
|
|
250
|
+
// birth) — a fixed `max` would go stale.
|
|
251
|
+
validation: ['min', 'max', 'notFuture'],
|
|
247
252
|
styles: INPUT_STYLES,
|
|
248
253
|
properties: [
|
|
249
254
|
FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'date' }]),
|
|
@@ -252,7 +257,8 @@ export const CONTROLS = {
|
|
|
252
257
|
title: 'Validation',
|
|
253
258
|
fields: [
|
|
254
259
|
{ path: 'props.validation.min', label: 'Earliest', type: 'date' },
|
|
255
|
-
{ path: 'props.validation.max', label: 'Latest', type: 'date' }
|
|
260
|
+
{ path: 'props.validation.max', label: 'Latest', type: 'date' },
|
|
261
|
+
{ path: 'props.validation.notFuture', label: 'Not in the future', type: 'toggle' }
|
|
256
262
|
]
|
|
257
263
|
},
|
|
258
264
|
...styleGroups(INPUT_STYLES)
|
|
@@ -268,7 +274,7 @@ export const CONTROLS = {
|
|
|
268
274
|
defaultSize: { w: 0.44, h: 0.05 },
|
|
269
275
|
defaults: { label: 'Checkbox', default: false },
|
|
270
276
|
// `required` on a checkbox means it must be ticked — "I accept".
|
|
271
|
-
props: ['name', 'label', 'required', 'default', 'style'],
|
|
277
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'style'],
|
|
272
278
|
validation: [],
|
|
273
279
|
styles: ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'color'],
|
|
274
280
|
properties: [
|
|
@@ -286,7 +292,7 @@ export const CONTROLS = {
|
|
|
286
292
|
valueType: null,
|
|
287
293
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
288
294
|
defaults: { label: 'Dropdown', placeholder: 'Select…', data: { source: 'static', options: [] } },
|
|
289
|
-
props: ['name', 'label', 'placeholder', 'required', 'default', 'data', 'style'],
|
|
295
|
+
props: ['name', 'label', 'preset', 'placeholder', 'required', 'default', 'data', 'style'],
|
|
290
296
|
validation: [],
|
|
291
297
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
292
298
|
properties: [
|
|
@@ -311,7 +317,7 @@ export const CONTROLS = {
|
|
|
311
317
|
valueType: null,
|
|
312
318
|
defaultSize: { w: 0.44, h: 0.14 },
|
|
313
319
|
defaults: { label: 'Choose one', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
314
|
-
props: ['name', 'label', 'required', 'default', 'data', 'layout', 'style'],
|
|
320
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'data', 'layout', 'style'],
|
|
315
321
|
validation: [],
|
|
316
322
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
317
323
|
properties: [
|
|
@@ -338,7 +344,7 @@ export const CONTROLS = {
|
|
|
338
344
|
valueType: 'array',
|
|
339
345
|
defaultSize: { w: 0.44, h: 0.18 },
|
|
340
346
|
defaults: { label: 'Choose any', data: { source: 'static', options: [] }, layout: 'vertical' },
|
|
341
|
-
props: ['name', 'label', 'required', 'data', 'layout', 'validation', 'style'],
|
|
347
|
+
props: ['name', 'label', 'preset', 'required', 'data', 'layout', 'validation', 'style'],
|
|
342
348
|
validation: ['minItems', 'maxItems'],
|
|
343
349
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
344
350
|
properties: [
|
|
@@ -371,7 +377,7 @@ export const CONTROLS = {
|
|
|
371
377
|
valueType: 'date',
|
|
372
378
|
defaultSize: { w: 0.44, h: 0.08 },
|
|
373
379
|
defaults: { label: 'Date and time' },
|
|
374
|
-
props: ['name', 'label', 'required', 'default', 'validation', 'style'],
|
|
380
|
+
props: ['name', 'label', 'preset', 'required', 'default', 'validation', 'style'],
|
|
375
381
|
validation: ['min', 'max'],
|
|
376
382
|
styles: INPUT_STYLES,
|
|
377
383
|
properties: [
|
|
@@ -397,7 +403,7 @@ export const CONTROLS = {
|
|
|
397
403
|
valueType: 'string',
|
|
398
404
|
defaultSize: { w: 0.44, h: 0.1 },
|
|
399
405
|
defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
|
|
400
|
-
props: ['name', 'label', 'required', 'accept', 'maxSize', 'style'],
|
|
406
|
+
props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'style'],
|
|
401
407
|
validation: [],
|
|
402
408
|
styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
|
|
403
409
|
properties: [
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
2
|
import { XeplrCanvas } from '@xeplr/ui-canvas'
|
|
3
3
|
import ControlView from './ControlView.jsx'
|
|
4
4
|
import Palette, { DRAG_TYPE } from './Palette.jsx'
|
|
5
5
|
import PropertyPanel from './PropertyPanel.jsx'
|
|
6
6
|
import { screenStyle } from './styles.js'
|
|
7
|
+
import { presetFor } from '../presets.js'
|
|
7
8
|
|
|
8
9
|
// The builder, drawn: name and save status on top, controls on the left, the
|
|
9
10
|
// screen on the canvas in the middle, properties on the right — the selected
|
|
@@ -14,12 +15,15 @@ import { screenStyle } from './styles.js'
|
|
|
14
15
|
// units with pageAspect = the screen's aspect: the same geometry ScreenSample
|
|
15
16
|
// renders with, so the builder shows the screen at its real size.
|
|
16
17
|
|
|
18
|
+
// NOTHING SAVES ITSELF — see the note in useFactoryBuilder. These say what
|
|
19
|
+
// IS, not what is about to happen on its own: "unsaved" is a fact about the
|
|
20
|
+
// screen, and the Save button beside it is what changes it.
|
|
17
21
|
const STATUS = {
|
|
18
22
|
idle: '',
|
|
19
|
-
|
|
23
|
+
unsaved: 'Unsaved changes',
|
|
20
24
|
saving: 'Saving…',
|
|
21
|
-
saved: '
|
|
22
|
-
invalid: '
|
|
25
|
+
saved: 'Saved',
|
|
26
|
+
invalid: 'Fix the problems to save',
|
|
23
27
|
error: 'Not saved'
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -30,8 +34,10 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
30
34
|
|
|
31
35
|
function onDrop(e) {
|
|
32
36
|
const type = e.dataTransfer.getData(DRAG_TYPE) || e.dataTransfer.getData('text/plain')
|
|
33
|
-
const
|
|
34
|
-
|
|
37
|
+
const preset = presetFor(type)
|
|
38
|
+
const base = ctrl.controls[preset ? preset.control : type]
|
|
39
|
+
if (!base) return
|
|
40
|
+
const def = { defaultSize: { ...base.defaultSize, ...(preset && preset.size) } }
|
|
35
41
|
e.preventDefault()
|
|
36
42
|
const inner = e.currentTarget.querySelector('.xeplr-canvas-inner')
|
|
37
43
|
if (!inner) return ctrl.addControl(type)
|
|
@@ -45,6 +51,16 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
45
51
|
})
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
// WITH NOTHING SAVING ITSELF, closing the tab is how work is lost. The
|
|
55
|
+
// browser's own question is the only one that can be asked this late, and
|
|
56
|
+
// it is asked ONLY when there is something to lose.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!ctrl.dirty) return undefined
|
|
59
|
+
function ask(e) { e.preventDefault(); e.returnValue = '' }
|
|
60
|
+
window.addEventListener('beforeunload', ask)
|
|
61
|
+
return () => window.removeEventListener('beforeunload', ask)
|
|
62
|
+
}, [ctrl.dirty])
|
|
63
|
+
|
|
48
64
|
return (
|
|
49
65
|
<div className={'xeplr-factory-builder' + (className ? ' ' + className : '')} style={style}>
|
|
50
66
|
<header className="xeplr-factory-bar">
|
|
@@ -63,8 +79,32 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
63
79
|
{problemCount} problem{problemCount === 1 ? '' : 's'}
|
|
64
80
|
</span>
|
|
65
81
|
)}
|
|
66
|
-
{
|
|
67
|
-
|
|
82
|
+
{/* Suggestions, not problems: they never stop a save or a publish.
|
|
83
|
+
Clicking one opens the field, where the panel offers the change. */}
|
|
84
|
+
{ctrl.hints.length > 0 && (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="xeplr-factory-hints"
|
|
88
|
+
title={ctrl.hints.map((h) => h.message).join('\n')}
|
|
89
|
+
onClick={() => ctrl.setSelected(new Set([ctrl.hints[0].nodeId]))}
|
|
90
|
+
>
|
|
91
|
+
{ctrl.hints.length} suggestion{ctrl.hints.length === 1 ? '' : 's'}
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
{/* SAVE IS AN ACT. It is offered whenever there is something to save
|
|
95
|
+
(and after a failure, which is the same thing), and it is the only
|
|
96
|
+
thing that writes the draft — trying a layout out and walking away
|
|
97
|
+
leaves the saved screen as it was. */}
|
|
98
|
+
{(ctrl.dirty || ctrl.status === 'error') && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className="xeplr-factory-secondary"
|
|
102
|
+
onClick={ctrl.save}
|
|
103
|
+
disabled={ctrl.saving || !ctrl.validation.ok}
|
|
104
|
+
title={ctrl.validation.ok ? 'Save this design' : 'Fix the problems first'}
|
|
105
|
+
>
|
|
106
|
+
{ctrl.status === 'error' ? 'Try saving again' : ctrl.saving ? 'Saving…' : 'Save'}
|
|
107
|
+
</button>
|
|
68
108
|
)}
|
|
69
109
|
{renderPreview && (
|
|
70
110
|
<button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
|
|
@@ -79,20 +119,35 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
79
119
|
</header>
|
|
80
120
|
|
|
81
121
|
{ctrl.publishResult && ctrl.publishResult.confirm && (
|
|
82
|
-
<div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm
|
|
122
|
+
<div className="xeplr-factory-publish is-confirm" role="alertdialog" aria-label="Confirm changes to saved data">
|
|
83
123
|
<div className="xeplr-factory-confirm-text">
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
124
|
+
{ctrl.publishResult.confirm.length > 0 && (
|
|
125
|
+
<>
|
|
126
|
+
<strong>Publishing removes {ctrl.publishResult.confirm.length === 1 ? 'a column' : `${ctrl.publishResult.confirm.length} columns`} and all the data in {ctrl.publishResult.confirm.length === 1 ? 'it' : 'them'}.</strong>
|
|
127
|
+
<ul>
|
|
128
|
+
{ctrl.publishResult.confirm.map((c) => (
|
|
129
|
+
<li key={c.column}><code>{c.column}</code> — {c.records} saved value{c.records === 1 ? '' : 's'}, for every company using this table</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
This cannot be undone. To keep the data, put the field back before publishing.
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
{/* A field that changed kind: every saved value was checked and fits. */}
|
|
136
|
+
{(ctrl.publishResult.convert || []).length > 0 && (
|
|
137
|
+
<>
|
|
138
|
+
<strong>Publishing converts saved values to their field's new kind.</strong>
|
|
139
|
+
<ul>
|
|
140
|
+
{ctrl.publishResult.convert.map((c) => (
|
|
141
|
+
<li key={c.column}><code>{c.column}</code> — {c.from} → {c.to}: all {c.records} saved value{c.records === 1 ? '' : 's'} fit; blank ones become empty</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
91
146
|
</div>
|
|
92
147
|
<div className="xeplr-factory-confirm-actions">
|
|
93
|
-
<button type="button" className=
|
|
94
|
-
onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column))}>
|
|
95
|
-
{ctrl.publishing ? 'Publishing…' : 'Remove and publish'}
|
|
148
|
+
<button type="button" className={ctrl.publishResult.confirm.length ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} disabled={ctrl.publishing}
|
|
149
|
+
onClick={() => ctrl.publish(ctrl.publishResult.confirm.map((c) => c.column), (ctrl.publishResult.convert || []).map((c) => c.column))}>
|
|
150
|
+
{ctrl.publishing ? 'Publishing…' : ctrl.publishResult.confirm.length ? 'Remove and publish' : 'Convert and publish'}
|
|
96
151
|
</button>
|
|
97
152
|
<button type="button" className="xeplr-factory-secondary" onClick={ctrl.clearPublishResult}>Cancel</button>
|
|
98
153
|
</div>
|
|
@@ -179,12 +234,15 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
|
|
|
179
234
|
doc={doc}
|
|
180
235
|
node={ctrl.selectedNode}
|
|
181
236
|
control={ctrl.selectedControl}
|
|
237
|
+
controls={ctrl.controls}
|
|
182
238
|
selectionCount={ctrl.selected.size}
|
|
183
239
|
errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : ctrl.errorsByNode._document}
|
|
184
240
|
tables={ctrl.tables}
|
|
185
241
|
screens={ctrl.screens}
|
|
186
242
|
lockedNames={ctrl.lockedNames}
|
|
187
243
|
onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
|
|
244
|
+
onConvert={(target) => ctrl.convertControl(ctrl.selectedNode.id, target)}
|
|
245
|
+
hint={ctrl.selectedNode ? ctrl.hints.find((h) => h.nodeId === ctrl.selectedNode.id) : null}
|
|
188
246
|
onScreenChange={ctrl.setScreen}
|
|
189
247
|
onRemove={ctrl.removeSelected}
|
|
190
248
|
/>
|
|
@@ -65,12 +65,21 @@ function inputProps(node, p, error, disabled, onBlur) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// What each keyboard setting makes the box: its type (the phone keyboard) and
|
|
69
|
+
// what the browser's autofill offers.
|
|
70
|
+
const TEXT_INPUT = {
|
|
71
|
+
email: { type: 'email', autoComplete: 'email', inputMode: 'email' },
|
|
72
|
+
tel: { type: 'tel', autoComplete: 'tel', inputMode: 'tel' },
|
|
73
|
+
url: { type: 'url', autoComplete: 'url', inputMode: 'url' }
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
function TextView({ node, p, value, error, onChange, onBlur, disabled }) {
|
|
77
|
+
const kind = TEXT_INPUT[p.inputType] || { type: 'text' }
|
|
69
78
|
return (
|
|
70
79
|
<Field node={node} p={p} error={error}>
|
|
71
80
|
<input
|
|
72
81
|
className="xeplr-factory-input"
|
|
73
|
-
|
|
82
|
+
{...kind}
|
|
74
83
|
placeholder={p.placeholder || ''}
|
|
75
84
|
value={value ?? ''}
|
|
76
85
|
onChange={(e) => onChange(e.target.value)}
|