@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 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.1",
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.0.6",
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
- style={{ width: width ? `min(${width + 48}px, 96vw)` : undefined }}
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
- const text = ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]
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={{ height: `${pageHeight}cqw` }}
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
- left: `${node.x * 100}%`,
191
- top: `${node.y * aspect * 100}cqw`,
192
- width: `${node.w * 100}%`,
193
- height: `${node.h * aspect * 100}cqw`
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
@@ -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 { position: absolute; box-sizing: border-box; }
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
+ }
@@ -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 ? u(s.fontSize) : undefined,
33
+ fontSize: s.fontSize != null ? font(s.fontSize) : undefined,
31
34
  fontWeight: s.fontWeight,
32
35
  fontStyle: s.fontStyle,
33
36
  textAlign: s.textAlign,