@xeplr/ui-factory 1.8.1 → 1.8.3
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 +22 -0
- package/package.json +5 -3
- package/src/designs/ScreenModal.jsx +2 -1
- package/src/designs/ScreenSample.jsx +22 -7
- package/src/designs/factory.css +60 -2
- package/src/designs/styles.js +4 -1
package/README.md
CHANGED
|
@@ -117,6 +117,21 @@ If `onSave` throws an error with `fields: [{ field, message }]` (what `createFac
|
|
|
117
117
|
|
|
118
118
|
Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, a date and time as `"YYYY-MM-DDTHH:MM"`, a dropdown or radio group as the option's own `id` (a table's numeric id stays a number), a multi-select as an **array** of ids, and a file as the stored file's **path**.
|
|
119
119
|
|
|
120
|
+
### On a phone
|
|
121
|
+
|
|
122
|
+
Below **640px wide** (the viewport, not the box the screen sits in) a screen stops being a scaled-down canvas and **stacks**: every control takes the full width, one per row, in reading order — top to bottom, left to right across a row, the same order `inputNodes` reads. Nothing to set and nothing to design twice: the positions you placed are simply not used there.
|
|
123
|
+
|
|
124
|
+
- Controls are drawn at their real size, not shrunk. Inputs are at least **16px** (smaller makes iOS zoom the page when a field is focused) and **44px** tall; tick boxes, radio buttons and Remove are thumb-sized.
|
|
125
|
+
- Error messages sit under their field and wrap, rather than being cut off at the screen's edge.
|
|
126
|
+
- The footer wraps: what the save is doing on its own line, the buttons under it sharing the width.
|
|
127
|
+
- A popup (a list's Edit / New) fills the screen.
|
|
128
|
+
- A list keeps its table, which scrolls sideways inside its card; its row buttons stay pinned at the right edge (that part is `@xeplr/ui-table`, 1.1.3 or later).
|
|
129
|
+
- An identifier still to be numbered takes no space.
|
|
130
|
+
|
|
131
|
+
Above 640px nothing changes: a screen is shown at its design width, scaled down only when its box is narrower. A screen in a narrow side panel on a desktop keeps its designed layout. The builder and the flow designer are not for phones and are left as they are.
|
|
132
|
+
|
|
133
|
+
How: `ScreenSample` hands each control's position to CSS as variables (`--xf-x`, `--xf-y`, `--xf-w`, `--xf-h`) and its reading-order place as `--xf-order`; `factory.css` uses the positions on a desktop and the order on a phone. A design of your own can do the same.
|
|
134
|
+
|
|
120
135
|
### Where each value is kept
|
|
121
136
|
|
|
122
137
|
A record is columns in a real table, never JSON — so the two types that are not
|
|
@@ -879,6 +894,13 @@ npm test
|
|
|
879
894
|
|
|
880
895
|
`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`.
|
|
881
896
|
|
|
897
|
+
```sh
|
|
898
|
+
npx playwright install chromium-headless-shell # once
|
|
899
|
+
npm run test:phone
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
`test/phone.check.js` opens the dev app (`dev/`) in a real browser at 360, 390 and 430px wide and holds every screen to the phone rules: the page never scrolls sideways; fields are one per row, full width, in reading order, none overlapping; inputs are 16px or larger and 44px tall; the footer's buttons are 44px and on screen; a list's row buttons are on screen without scrolling the table; a popup fills the screen. At 1280px it checks that the designed layout is untouched. It is not part of `npm test` — it needs a browser and starts a dev server — so run it before releasing anything that changes how a screen is drawn. The list checks use the `@xeplr/ui-table` installed here.
|
|
903
|
+
|
|
882
904
|
## License
|
|
883
905
|
|
|
884
906
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-factory",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.3",
|
|
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",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"dev": "vite",
|
|
22
|
-
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
|
|
22
|
+
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done",
|
|
23
|
+
"test:phone": "node test/phone.check.js"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"form-builder",
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
"@vitejs/plugin-react": "^5.2.0",
|
|
57
58
|
"@xeplr/schema-handler": "^1.0.5",
|
|
58
59
|
"@xeplr/ui-canvas": "^1.0.1",
|
|
59
|
-
"@xeplr/ui-table": "^1.
|
|
60
|
+
"@xeplr/ui-table": "^1.1.3",
|
|
61
|
+
"playwright": "^1.64.1",
|
|
60
62
|
"react": "^19.2.4",
|
|
61
63
|
"react-dom": "^19.2.4",
|
|
62
64
|
"react-router-dom": "^7.18.4",
|
|
@@ -29,7 +29,8 @@ export default function ScreenModal({ title, width, onClose, children }) {
|
|
|
29
29
|
aria-modal="true"
|
|
30
30
|
aria-label={title}
|
|
31
31
|
tabIndex={-1}
|
|
32
|
-
|
|
32
|
+
// A variable, not a width: on a phone factory.css makes it the full screen.
|
|
33
|
+
style={width ? { '--xf-modal-w': `min(${width + 48}px, 96vw)` } : undefined}
|
|
33
34
|
>
|
|
34
35
|
<header className="xeplr-factory-modal-head">
|
|
35
36
|
<span className="xeplr-factory-modal-title">{title}</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ControlView from './ControlView.jsx'
|
|
2
|
-
import { contentBottom, inputNodes, MARGIN } from '../document.js'
|
|
2
|
+
import { contentBottom, inputNodes, readingOrder, MARGIN } from '../document.js'
|
|
3
3
|
import { screenStyle } from './styles.js'
|
|
4
4
|
import { CHOICE_TYPES } from '../controls.js'
|
|
5
5
|
|
|
@@ -11,6 +11,12 @@ import { CHOICE_TYPES } from '../controls.js'
|
|
|
11
11
|
// The screen is never wider than the width it was designed at, so what was
|
|
12
12
|
// designed at 800px is shown at 800px, and only a narrower screen scales it.
|
|
13
13
|
//
|
|
14
|
+
// ON A PHONE IT STACKS. Positions are handed to CSS as variables (--xf-x, …),
|
|
15
|
+
// not as left/top, so factory.css can drop them below 640px and lay the fields
|
|
16
|
+
// out one per row in reading order (--xf-order) — full width, at real sizes.
|
|
17
|
+
// Scaled down instead, an 800px design on a 360px phone is text in boxes too
|
|
18
|
+
// small to tap. The desktop layout does not change.
|
|
19
|
+
//
|
|
14
20
|
// SAVE IS A BUTTON, AND AN AJAX CALL — never a form submit. The <form> below
|
|
15
21
|
// exists for semantics only: its submit is swallowed, so Enter in a field
|
|
16
22
|
// posts nothing and saves nothing. Only the Save button saves.
|
|
@@ -30,7 +36,12 @@ const STATUS = {
|
|
|
30
36
|
|
|
31
37
|
/** What the background save is doing, in words. */
|
|
32
38
|
function SaveStatus({ ctrl }) {
|
|
33
|
-
|
|
39
|
+
// A screen with no table of its own (a review in a workflow) saves nothing,
|
|
40
|
+
// so "Unsaved changes" and "Saved" would describe something that is not
|
|
41
|
+
// happening. What still matters there is a field left wrong.
|
|
42
|
+
const keeps = Boolean(ctrl.document && ctrl.document.source)
|
|
43
|
+
const quiet = !keeps && (ctrl.status === 'pending' || ctrl.status === 'saved' || ctrl.status === 'saving')
|
|
44
|
+
const text = quiet ? '' : ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
|
|
34
45
|
return (
|
|
35
46
|
<span className={`xeplr-factory-footer-status xeplr-factory-footer-status--${ctrl.status}`} role="status" aria-live="polite">
|
|
36
47
|
{text}
|
|
@@ -159,6 +170,9 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
159
170
|
// filled in, not as tall as every step stacked up.
|
|
160
171
|
const shown = ctrl.visibleNodes && ctrl.visibleNodes.length ? ctrl.visibleNodes : doc.nodes
|
|
161
172
|
const pageHeight = (contentBottom(doc, shown) + MARGIN) * aspect * 100
|
|
173
|
+
// Where each control comes when the screen stacks (phones): read top to
|
|
174
|
+
// bottom, left to right across a row — the order the design is read in.
|
|
175
|
+
const order = new Map(readingOrder(shown).map((n, i) => [n.id, i]))
|
|
162
176
|
const fieldNodes = inputNodes(doc)
|
|
163
177
|
// A screen with no fields of its own (a list screen) has nothing to save —
|
|
164
178
|
// and no sheet: its list is the card (factory.css, --bare).
|
|
@@ -177,7 +191,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
177
191
|
>
|
|
178
192
|
<Page
|
|
179
193
|
className="xeplr-factory-page"
|
|
180
|
-
style={{
|
|
194
|
+
style={{ '--xf-page-h': `${pageHeight}cqw` }}
|
|
181
195
|
// Never a submit: Enter posts nothing and saves nothing. Save is a button.
|
|
182
196
|
{...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
|
|
183
197
|
aria-label={doc.name}
|
|
@@ -187,10 +201,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
|
|
|
187
201
|
key={node.id}
|
|
188
202
|
className={`xeplr-factory-node xeplr-factory-node--${node.type}`}
|
|
189
203
|
style={{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
'--xf-x': `${node.x * 100}%`,
|
|
205
|
+
'--xf-y': `${node.y * aspect * 100}cqw`,
|
|
206
|
+
'--xf-w': `${node.w * 100}%`,
|
|
207
|
+
'--xf-h': `${node.h * aspect * 100}cqw`,
|
|
208
|
+
'--xf-order': order.get(node.id)
|
|
194
209
|
}}
|
|
195
210
|
>
|
|
196
211
|
<ControlView
|
package/src/designs/factory.css
CHANGED
|
@@ -65,8 +65,11 @@
|
|
|
65
65
|
its own card, and a sheet around it is a second frame with a block of empty
|
|
66
66
|
paper under the rows. */
|
|
67
67
|
.xeplr-factory-screen--bare { background: none; border: none; }
|
|
68
|
-
.xeplr-factory-page { position: relative; width: 100%; margin: 0; }
|
|
69
|
-
.xeplr-factory-node {
|
|
68
|
+
.xeplr-factory-page { position: relative; width: 100%; margin: 0; height: var(--xf-page-h); }
|
|
69
|
+
.xeplr-factory-node {
|
|
70
|
+
position: absolute; box-sizing: border-box;
|
|
71
|
+
left: var(--xf-x); top: var(--xf-y); width: var(--xf-w); height: var(--xf-h);
|
|
72
|
+
}
|
|
70
73
|
|
|
71
74
|
.xeplr-factory-invalid {
|
|
72
75
|
border: 1px solid var(--xeplr-danger, #c2372e); border-radius: 8px; padding: 12px 16px;
|
|
@@ -433,6 +436,7 @@
|
|
|
433
436
|
background: var(--xeplr-bg-overlay, rgba(20, 20, 18, .45));
|
|
434
437
|
}
|
|
435
438
|
.xeplr-factory-modal {
|
|
439
|
+
width: var(--xf-modal-w);
|
|
436
440
|
max-width: 96vw; background: var(--xf-surface); color: var(--xf-ink);
|
|
437
441
|
border-radius: 10px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); outline: none;
|
|
438
442
|
}
|
|
@@ -645,3 +649,57 @@
|
|
|
645
649
|
.xeplr-factory-table td.is-number { text-align: right; font-variant-numeric: tabular-nums; }
|
|
646
650
|
.xeplr-factory-table tr.is-ticked td { background: var(--xeplr-accent-soft, rgba(47, 91, 211, 0.07)); }
|
|
647
651
|
.xeplr-factory-table-tick { width: 28px; }
|
|
652
|
+
|
|
653
|
+
/* ── on a phone: one column ────────────────────────────────────────────────
|
|
654
|
+
Below 640px a screen stops being a scaled-down canvas. An 800px design
|
|
655
|
+
shrunk to a 360px phone is 14px text squeezed into boxes under half their
|
|
656
|
+
size, side-by-side fields too narrow to read and targets too small to tap.
|
|
657
|
+
So here every control takes the full width, one per row, in reading order
|
|
658
|
+
(--xf-order, from ScreenSample), at its real size: --xf-u is a pixel again
|
|
659
|
+
and the positions (--xf-x, …) are simply not used. A phone, not a narrow
|
|
660
|
+
panel: the viewport decides, so a screen in a side panel on a desktop keeps
|
|
661
|
+
its designed layout. Designers (builder, flow designer) are not for phones
|
|
662
|
+
and are left as they are. */
|
|
663
|
+
@media (max-width: 640px) {
|
|
664
|
+
.xeplr-factory-screen { --xf-u: 1px; }
|
|
665
|
+
.xeplr-factory-page {
|
|
666
|
+
display: flex; flex-direction: column; gap: 18px;
|
|
667
|
+
height: auto; padding: 16px; box-sizing: border-box;
|
|
668
|
+
}
|
|
669
|
+
.xeplr-factory-node { position: static; width: auto; height: auto; order: var(--xf-order); }
|
|
670
|
+
/* An identifier still to be numbered draws nothing: no gap for it either. */
|
|
671
|
+
.xeplr-factory-node:empty { display: none; }
|
|
672
|
+
|
|
673
|
+
.xeplr-factory-field { height: auto; }
|
|
674
|
+
/* 16px or more, or iOS zooms the page when a field is focused; 44px tall,
|
|
675
|
+
the smallest target a thumb hits reliably. */
|
|
676
|
+
.xeplr-factory-input { --xf-min-font: 16px; font-size: max(16px, 1em); flex: none; min-height: 44px; }
|
|
677
|
+
.xeplr-factory-textarea { min-height: 112px; }
|
|
678
|
+
.xeplr-factory-file-input { min-height: 0; }
|
|
679
|
+
/* In the flow now: a message may push the next field down, and wraps
|
|
680
|
+
rather than being cut off at the edge of a narrow screen. */
|
|
681
|
+
.xeplr-factory-error { position: static; white-space: normal; }
|
|
682
|
+
.xeplr-factory-check, .xeplr-factory-choice { min-height: 40px; }
|
|
683
|
+
.xeplr-factory-check input { width: 22px; height: 22px; }
|
|
684
|
+
.xeplr-factory-choice input { width: 20px; height: 20px; }
|
|
685
|
+
.xeplr-factory-choices { overflow: visible; }
|
|
686
|
+
.xeplr-factory-file-clear { min-height: 40px; padding-inline: 14px; }
|
|
687
|
+
.xeplr-factory-text { height: auto; overflow: visible; }
|
|
688
|
+
.xeplr-factory-stepper { height: auto; }
|
|
689
|
+
.xeplr-factory-list { max-height: none; }
|
|
690
|
+
.xeplr-factory-table-wrap { max-height: 60vh; }
|
|
691
|
+
.xeplr-factory-subform { height: auto; overflow: visible; }
|
|
692
|
+
|
|
693
|
+
/* The footer wraps: what the save is doing on a line of its own, the
|
|
694
|
+
buttons under it, sharing the width. */
|
|
695
|
+
.xeplr-factory-footer { flex-wrap: wrap; padding: 12px 16px; }
|
|
696
|
+
.xeplr-factory-footer-mid { flex-basis: 100%; order: -1; align-items: flex-start; text-align: left; }
|
|
697
|
+
.xeplr-factory-footer button { flex: 1; min-height: 44px; }
|
|
698
|
+
.xeplr-factory-list-new { min-height: 36px; }
|
|
699
|
+
|
|
700
|
+
/* A popup is the whole screen. */
|
|
701
|
+
.xeplr-factory-modal-backdrop { padding: 0; }
|
|
702
|
+
.xeplr-factory-modal { width: 100%; max-width: none; min-height: 100%; border-radius: 0; }
|
|
703
|
+
.xeplr-factory-modal-body { padding: 0; }
|
|
704
|
+
.xeplr-factory-modal-close { min-width: 44px; min-height: 44px; }
|
|
705
|
+
}
|
package/src/designs/styles.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// shrinks to keep the layout's proportions.
|
|
7
7
|
|
|
8
8
|
const u = (n) => `calc(${n} * var(--xf-u))`
|
|
9
|
+
// A floor the stylesheet can raise: on a phone an input under 16px makes iOS
|
|
10
|
+
// zoom the page on focus, so factory.css sets --xf-min-font on inputs there.
|
|
11
|
+
const font = (n) => `max(var(--xf-min-font, 0px), ${u(n)})`
|
|
9
12
|
|
|
10
13
|
/** Wrapper-level: the font the whole control uses. */
|
|
11
14
|
export function fieldStyle(style) {
|
|
@@ -27,7 +30,7 @@ export function labelStyle(style) {
|
|
|
27
30
|
export function boxStyle(style) {
|
|
28
31
|
const s = style || {}
|
|
29
32
|
return clean({
|
|
30
|
-
fontSize: s.fontSize != null ?
|
|
33
|
+
fontSize: s.fontSize != null ? font(s.fontSize) : undefined,
|
|
31
34
|
fontWeight: s.fontWeight,
|
|
32
35
|
fontStyle: s.fontStyle,
|
|
33
36
|
textAlign: s.textAlign,
|