@xeplr/ui-factory 1.8.2 → 1.8.4
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 +21 -1
- package/README.md +190 -5
- package/bin/xeplr-factory.js +27 -1
- package/package.json +7 -3
- package/src/buttons.js +142 -0
- package/src/controls.js +48 -1
- package/src/designs/BuilderSample.jsx +1 -0
- package/src/designs/ButtonsView.jsx +107 -0
- package/src/designs/ControlView.jsx +4 -1
- package/src/designs/FedList.jsx +248 -0
- package/src/designs/ListView.jsx +6 -1
- package/src/designs/PropertyPanel.jsx +336 -4
- package/src/designs/ScreenModal.jsx +2 -1
- package/src/designs/ScreenSample.jsx +28 -7
- package/src/designs/factory.css +199 -2
- package/src/designs/styles.js +4 -1
- package/src/events.js +21 -0
- package/src/model.js +8 -1
- package/src/pagesInventory.js +91 -0
- package/src/remote.js +17 -2
- package/src/urlState.js +2 -2
- package/src/useFactoryBuilder.js +9 -1
- package/src/useFactoryScreen.js +103 -8
- package/src/useUrlPlaces.js +13 -0
- package/src/validateDocument.js +147 -3
package/src/designs/factory.css
CHANGED
|
@@ -65,8 +65,17 @@
|
|
|
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
|
+
}
|
|
73
|
+
/* A list that grows with its rows (props.grow), or a panel of buttons — its
|
|
74
|
+
screen's one control: in the flow, where it was placed across, as tall as
|
|
75
|
+
it needs. */
|
|
76
|
+
.xeplr-factory-page--grows { height: auto; }
|
|
77
|
+
.xeplr-factory-page--grows > .xeplr-factory-node { position: static; margin: var(--xf-y) 0 0 var(--xf-x); height: auto; }
|
|
78
|
+
.xeplr-factory-page--grows .xeplr-factory-list { max-height: none; }
|
|
70
79
|
|
|
71
80
|
.xeplr-factory-invalid {
|
|
72
81
|
border: 1px solid var(--xeplr-danger, #c2372e); border-radius: 8px; padding: 12px 16px;
|
|
@@ -285,6 +294,104 @@
|
|
|
285
294
|
.xeplr-factory-list .xeplr-table-row.xeplr-factory-row-open td,
|
|
286
295
|
.xeplr-factory-list tr.xeplr-factory-row-open td { background: color-mix(in srgb, var(--xf-accent) 10%, transparent); }
|
|
287
296
|
|
|
297
|
+
/* ── a list whose rows a function works out ────────────────────────────────
|
|
298
|
+
Search and chips over the rows; every cell is text the server wrote. */
|
|
299
|
+
.xeplr-factory-list--fed { overflow: hidden; }
|
|
300
|
+
.xeplr-factory-list--fed .xeplr-table-container { border: none; border-radius: 0; background: transparent; }
|
|
301
|
+
.xeplr-factory-list-tools {
|
|
302
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px;
|
|
303
|
+
border-bottom: 1px solid var(--xf-border);
|
|
304
|
+
}
|
|
305
|
+
.xeplr-factory-list-tools .xeplr-factory-list-title { flex: 0 1 auto; }
|
|
306
|
+
.xeplr-factory-list-search { flex: 1 1 220px; max-width: 360px; min-width: 0; }
|
|
307
|
+
.xeplr-factory-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
308
|
+
.xeplr-factory-chip {
|
|
309
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
310
|
+
font: inherit; font-size: .9em; padding: 4px 10px; border-radius: 999px; cursor: pointer; white-space: nowrap;
|
|
311
|
+
color: var(--xf-ink); background: transparent; border: 1px solid var(--xf-border);
|
|
312
|
+
}
|
|
313
|
+
.xeplr-factory-chip:hover { background: var(--xf-hover); }
|
|
314
|
+
.xeplr-factory-chip.is-on { color: var(--xf-accent-ink); background: var(--xf-accent); border-color: var(--xf-accent); }
|
|
315
|
+
.xeplr-factory-chip:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); }
|
|
316
|
+
.xeplr-factory-chip-count { font-size: .85em; opacity: .75; font-variant-numeric: tabular-nums; }
|
|
317
|
+
.xeplr-factory-fed-table.is-openable tbody tr { cursor: pointer; }
|
|
318
|
+
.xeplr-factory-list tr.xeplr-factory-row-marked td { background: color-mix(in srgb, var(--xf-accent) 6%, transparent); }
|
|
319
|
+
.xeplr-factory-list tr.xeplr-factory-row-marked td:first-child { box-shadow: inset 3px 0 0 var(--xf-accent); }
|
|
320
|
+
.xeplr-factory-cell { display: inline-flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; row-gap: 2px; min-width: 0; }
|
|
321
|
+
.xeplr-factory-cell-open { font: inherit; color: inherit; background: none; border: none; padding: 0; text-align: left; cursor: pointer; }
|
|
322
|
+
.xeplr-factory-cell-open .xeplr-factory-cell-main { font-weight: 600; color: var(--xf-accent); }
|
|
323
|
+
.xeplr-factory-cell-open:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); border-radius: 4px; }
|
|
324
|
+
.xeplr-factory-cell-sub { flex-basis: 100%; font-size: .88em; color: var(--xf-muted); }
|
|
325
|
+
.xeplr-factory-badge {
|
|
326
|
+
font-size: .75em; font-weight: 600; padding: 1px 7px; border-radius: 999px; white-space: nowrap;
|
|
327
|
+
color: var(--xf-accent); background: color-mix(in srgb, var(--xf-accent) 14%, transparent);
|
|
328
|
+
}
|
|
329
|
+
.xeplr-factory-badge--success { color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 14%, transparent); }
|
|
330
|
+
.xeplr-factory-badge--warning { color: var(--xf-warn); background: color-mix(in srgb, var(--xf-warn) 16%, transparent); }
|
|
331
|
+
.xeplr-factory-badge--danger { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 14%, transparent); }
|
|
332
|
+
.xeplr-factory-badge--muted { color: var(--xf-muted); background: var(--xf-hover); }
|
|
333
|
+
.xeplr-factory-thumbs { display: inline-flex; align-items: center; gap: 4px; }
|
|
334
|
+
.xeplr-factory-thumbs img { width: 36px; height: 36px; object-fit: cover; border-radius: 6px; border: 1px solid var(--xf-border); background: var(--xf-plane); }
|
|
335
|
+
.xeplr-factory-thumbs small { color: var(--xf-muted); }
|
|
336
|
+
.xeplr-factory-activity { max-width: 280px; white-space: normal; }
|
|
337
|
+
.xeplr-factory-activity[tabindex]:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); border-radius: 4px; }
|
|
338
|
+
/* At the page's root (a portal), so it reads the app's theme directly. */
|
|
339
|
+
.xeplr-factory-activity-box {
|
|
340
|
+
position: fixed; z-index: 1200; box-sizing: border-box; overflow-y: auto; padding: 6px;
|
|
341
|
+
max-width: calc(100vw - 16px);
|
|
342
|
+
font-size: 13px; color: var(--xeplr-text-primary, #1a1a19);
|
|
343
|
+
background: var(--xeplr-bg-tertiary, #ffffff); border: 1px solid var(--xeplr-border-primary, #d9d8d0);
|
|
344
|
+
border-radius: 10px; box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
|
|
345
|
+
}
|
|
346
|
+
.xeplr-factory-activity-box .xeplr-factory-hint { padding: 8px; }
|
|
347
|
+
.xeplr-factory-activity-item {
|
|
348
|
+
display: flex; flex-direction: column; gap: 2px; width: 100%; box-sizing: border-box;
|
|
349
|
+
padding: 7px 8px; border-radius: 6px; font: inherit; color: inherit; text-align: left; background: none; border: none;
|
|
350
|
+
}
|
|
351
|
+
button.xeplr-factory-activity-item { cursor: pointer; }
|
|
352
|
+
button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:focus-visible { outline: none; background: var(--xeplr-bg-hover, #f0f0ee); }
|
|
353
|
+
.xeplr-factory-activity-sub { font-size: .9em; color: var(--xeplr-text-secondary, #6b6a63); }
|
|
354
|
+
|
|
355
|
+
/* ── a panel of buttons ─────────────────────────────────────────────────── */
|
|
356
|
+
.xeplr-factory-buttons {
|
|
357
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
|
|
358
|
+
font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
|
|
359
|
+
}
|
|
360
|
+
.xeplr-factory-buttons-title { font-weight: 600; color: var(--xf-muted); margin-right: 4px; }
|
|
361
|
+
.xeplr-factory-button {
|
|
362
|
+
font: inherit; font-weight: 600; font-size: .92em; padding: 6px 13px; border-radius: 6px; cursor: pointer; white-space: nowrap;
|
|
363
|
+
color: var(--xf-ink); background: var(--xf-surface); border: 1px solid var(--xf-border);
|
|
364
|
+
transition: background .12s, transform .08s, box-shadow .12s;
|
|
365
|
+
}
|
|
366
|
+
.xeplr-factory-button:hover:not(:disabled) { background: var(--xf-hover); }
|
|
367
|
+
.xeplr-factory-button:active:not(:disabled) { transform: scale(.96); }
|
|
368
|
+
.xeplr-factory-button:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); }
|
|
369
|
+
.xeplr-factory-button:disabled { opacity: .5; cursor: default; }
|
|
370
|
+
.xeplr-factory-button--primary { color: var(--xf-accent-ink); background: var(--xf-accent); border-color: var(--xf-accent); }
|
|
371
|
+
.xeplr-factory-button--primary:hover:not(:disabled) { background: var(--xf-accent); filter: brightness(1.08); }
|
|
372
|
+
.xeplr-factory-button--danger { color: var(--xf-danger); border-color: color-mix(in srgb, var(--xf-danger) 55%, var(--xf-border)); }
|
|
373
|
+
.xeplr-factory-button--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
|
|
374
|
+
.xeplr-factory-buttons--design .xeplr-factory-button { cursor: inherit; }
|
|
375
|
+
.xeplr-factory-buttons-note {
|
|
376
|
+
display: inline-flex; align-items: center; gap: 6px; font-size: .9em; padding: 4px 6px 4px 10px; border-radius: 6px;
|
|
377
|
+
color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 10%, transparent);
|
|
378
|
+
}
|
|
379
|
+
.xeplr-factory-buttons-note--error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
|
|
380
|
+
.xeplr-factory-buttons-note-close { font: inherit; font-size: 1.1em; line-height: 1; color: inherit; background: none; border: none; cursor: pointer; padding: 2px 4px; }
|
|
381
|
+
.xeplr-factory-ask { --xf-modal-w: min(440px, 96vw); }
|
|
382
|
+
.xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 18px 8px; display: flex; flex-direction: column; gap: 10px; }
|
|
383
|
+
.xeplr-factory-ask-message { margin: 0; color: var(--xf-muted); }
|
|
384
|
+
.xeplr-factory-ask-field { display: flex; flex-direction: column; gap: 4px; }
|
|
385
|
+
.xeplr-factory-ask-label { font-weight: 600; font-size: .92em; }
|
|
386
|
+
.xeplr-factory-ask-note, .xeplr-factory-list-search {
|
|
387
|
+
box-sizing: border-box; width: 100%; font: inherit; padding: 7px 10px; border-radius: 6px;
|
|
388
|
+
color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
|
|
389
|
+
}
|
|
390
|
+
.xeplr-factory-ask-note { resize: vertical; min-height: 80px; }
|
|
391
|
+
.xeplr-factory-ask-note:focus, .xeplr-factory-list-search:focus { outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 25%, transparent); }
|
|
392
|
+
.xeplr-factory-ask-error { margin: 0; color: var(--xf-danger); }
|
|
393
|
+
.xeplr-factory-ask-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; }
|
|
394
|
+
|
|
288
395
|
/* ── the builder ────────────────────────────────────────────────────────── */
|
|
289
396
|
.xeplr-factory-builder {
|
|
290
397
|
display: flex; flex-direction: column; height: 100%; min-height: 560px;
|
|
@@ -394,6 +501,27 @@
|
|
|
394
501
|
.xeplr-factory-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
395
502
|
.xeplr-factory-panel-type { font-weight: 700; font-size: 14px; }
|
|
396
503
|
.xeplr-factory-link-danger { background: none; border: none; color: var(--xf-danger); cursor: pointer; font: inherit; padding: 2px 4px; }
|
|
504
|
+
.xeplr-factory-link { background: none; border: none; color: var(--xf-accent); cursor: pointer; font: inherit; padding: 2px 4px; }
|
|
505
|
+
.xeplr-factory-link:disabled { color: var(--xf-muted); opacity: .5; cursor: default; }
|
|
506
|
+
/* Lists of things edited in place: chips, columns, buttons, conditions. */
|
|
507
|
+
.xeplr-factory-rows-editor { display: flex; flex-direction: column; gap: 8px; }
|
|
508
|
+
.xeplr-factory-rows-editor > .xeplr-factory-secondary { align-self: flex-start; padding: 4px 10px; }
|
|
509
|
+
.xeplr-factory-rows-editor-row { display: flex; align-items: center; gap: 4px; }
|
|
510
|
+
.xeplr-factory-rows-editor-row .xeplr-factory-prop-input { flex: 1 1 0; min-width: 0; }
|
|
511
|
+
.xeplr-factory-prop-input--key { flex: 0 1 38% !important; font-family: ui-monospace, monospace; font-size: 11px; }
|
|
512
|
+
.xeplr-factory-rows-editor-card {
|
|
513
|
+
position: relative; display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 8px 10px 10px;
|
|
514
|
+
border: 1px solid var(--xf-border); border-radius: 8px;
|
|
515
|
+
}
|
|
516
|
+
.xeplr-factory-rows-editor-card > legend { font-weight: 600; font-size: 12px; padding: 0 4px; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
517
|
+
.xeplr-factory-rows-editor-card > .xeplr-factory-rows-editor-tools { position: absolute; top: -9px; right: 6px; background: var(--xf-surface); padding: 0 2px; }
|
|
518
|
+
.xeplr-factory-mini { display: flex; flex-direction: column; gap: 2px; }
|
|
519
|
+
.xeplr-factory-mini-label { font-size: 11px; font-weight: 600; color: var(--xf-muted); }
|
|
520
|
+
.xeplr-factory-conditions { display: flex; flex-direction: column; gap: 4px; }
|
|
521
|
+
.xeplr-factory-conditions > .xeplr-factory-link { align-self: flex-start; padding-left: 0; }
|
|
522
|
+
/* The field on a line of its own (names are long); the comparison and value under it. */
|
|
523
|
+
.xeplr-factory-conditions .xeplr-factory-rows-editor-row { flex-wrap: wrap; padding-bottom: 6px; border-bottom: 1px dashed var(--xf-border); }
|
|
524
|
+
.xeplr-factory-conditions .xeplr-factory-rows-editor-row > input:first-child { flex: 1 1 100%; }
|
|
397
525
|
.xeplr-factory-group { border: none; border-top: 1px solid var(--xf-border); margin: 0; padding: 10px 0 4px; }
|
|
398
526
|
.xeplr-factory-group legend { padding: 0 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--xf-muted); }
|
|
399
527
|
.xeplr-factory-prop { margin-bottom: 10px; }
|
|
@@ -433,6 +561,7 @@
|
|
|
433
561
|
background: var(--xeplr-bg-overlay, rgba(20, 20, 18, .45));
|
|
434
562
|
}
|
|
435
563
|
.xeplr-factory-modal {
|
|
564
|
+
width: var(--xf-modal-w);
|
|
436
565
|
max-width: 96vw; background: var(--xf-surface); color: var(--xf-ink);
|
|
437
566
|
border-radius: 10px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); outline: none;
|
|
438
567
|
}
|
|
@@ -645,3 +774,71 @@
|
|
|
645
774
|
.xeplr-factory-table td.is-number { text-align: right; font-variant-numeric: tabular-nums; }
|
|
646
775
|
.xeplr-factory-table tr.is-ticked td { background: var(--xeplr-accent-soft, rgba(47, 91, 211, 0.07)); }
|
|
647
776
|
.xeplr-factory-table-tick { width: 28px; }
|
|
777
|
+
|
|
778
|
+
/* ── on a phone: one column ────────────────────────────────────────────────
|
|
779
|
+
Below 640px a screen stops being a scaled-down canvas. An 800px design
|
|
780
|
+
shrunk to a 360px phone is 14px text squeezed into boxes under half their
|
|
781
|
+
size, side-by-side fields too narrow to read and targets too small to tap.
|
|
782
|
+
So here every control takes the full width, one per row, in reading order
|
|
783
|
+
(--xf-order, from ScreenSample), at its real size: --xf-u is a pixel again
|
|
784
|
+
and the positions (--xf-x, …) are simply not used. A phone, not a narrow
|
|
785
|
+
panel: the viewport decides, so a screen in a side panel on a desktop keeps
|
|
786
|
+
its designed layout. Designers (builder, flow designer) are not for phones
|
|
787
|
+
and are left as they are. */
|
|
788
|
+
@media (max-width: 640px) {
|
|
789
|
+
.xeplr-factory-screen { --xf-u: 1px; }
|
|
790
|
+
.xeplr-factory-page {
|
|
791
|
+
display: flex; flex-direction: column; gap: 18px;
|
|
792
|
+
height: auto; padding: 16px; box-sizing: border-box;
|
|
793
|
+
}
|
|
794
|
+
.xeplr-factory-node { position: static; width: auto; height: auto; order: var(--xf-order); }
|
|
795
|
+
/* An identifier still to be numbered draws nothing: no gap for it either. */
|
|
796
|
+
.xeplr-factory-node:empty { display: none; }
|
|
797
|
+
|
|
798
|
+
.xeplr-factory-field { height: auto; }
|
|
799
|
+
/* 16px or more, or iOS zooms the page when a field is focused; 44px tall,
|
|
800
|
+
the smallest target a thumb hits reliably. */
|
|
801
|
+
.xeplr-factory-input { --xf-min-font: 16px; font-size: max(16px, 1em); flex: none; min-height: 44px; }
|
|
802
|
+
.xeplr-factory-textarea { min-height: 112px; }
|
|
803
|
+
.xeplr-factory-file-input { min-height: 0; }
|
|
804
|
+
/* In the flow now: a message may push the next field down, and wraps
|
|
805
|
+
rather than being cut off at the edge of a narrow screen. */
|
|
806
|
+
.xeplr-factory-error { position: static; white-space: normal; }
|
|
807
|
+
.xeplr-factory-check, .xeplr-factory-choice { min-height: 40px; }
|
|
808
|
+
.xeplr-factory-check input { width: 22px; height: 22px; }
|
|
809
|
+
.xeplr-factory-choice input { width: 20px; height: 20px; }
|
|
810
|
+
.xeplr-factory-choices { overflow: visible; }
|
|
811
|
+
.xeplr-factory-file-clear { min-height: 40px; padding-inline: 14px; }
|
|
812
|
+
.xeplr-factory-text { height: auto; overflow: visible; }
|
|
813
|
+
.xeplr-factory-stepper { height: auto; }
|
|
814
|
+
.xeplr-factory-list { max-height: none; }
|
|
815
|
+
.xeplr-factory-table-wrap { max-height: 60vh; }
|
|
816
|
+
.xeplr-factory-subform { height: auto; overflow: visible; }
|
|
817
|
+
|
|
818
|
+
/* The footer wraps: what the save is doing on a line of its own, the
|
|
819
|
+
buttons under it, sharing the width. */
|
|
820
|
+
.xeplr-factory-footer { flex-wrap: wrap; padding: 12px 16px; }
|
|
821
|
+
.xeplr-factory-footer-mid { flex-basis: 100%; order: -1; align-items: flex-start; text-align: left; }
|
|
822
|
+
.xeplr-factory-footer button { flex: 1; min-height: 44px; }
|
|
823
|
+
.xeplr-factory-list-new { min-height: 36px; }
|
|
824
|
+
|
|
825
|
+
/* A popup is the whole screen. */
|
|
826
|
+
.xeplr-factory-modal-backdrop { padding: 0; }
|
|
827
|
+
.xeplr-factory-modal { width: 100%; max-width: none; min-height: 100%; border-radius: 0; }
|
|
828
|
+
.xeplr-factory-modal-body { padding: 0; }
|
|
829
|
+
.xeplr-factory-modal-close { min-width: 44px; min-height: 44px; }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* Function-fed lists and buttons on a phone: the search takes the width, chips
|
|
833
|
+
scroll in a row, and every button is a thumb-sized target. */
|
|
834
|
+
@media (max-width: 640px) {
|
|
835
|
+
.xeplr-factory-list-search, .xeplr-factory-ask-note { font-size: max(16px, 1em); min-height: 44px; }
|
|
836
|
+
.xeplr-factory-list-search { flex-basis: 100%; max-width: none; }
|
|
837
|
+
.xeplr-factory-ask-foot button { flex: 1; min-height: 44px; }
|
|
838
|
+
.xeplr-factory-chips { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; padding-bottom: 2px; }
|
|
839
|
+
.xeplr-factory-chip { min-height: 36px; }
|
|
840
|
+
.xeplr-factory-button { min-height: 44px; flex: 1 1 auto; }
|
|
841
|
+
.xeplr-factory-buttons-note { flex-basis: 100%; }
|
|
842
|
+
.xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 16px 8px; }
|
|
843
|
+
.xeplr-factory-ask { min-height: 0; align-self: flex-end; }
|
|
844
|
+
}
|
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,
|
package/src/events.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// What each is called with, and what it may return, is the contract a
|
|
14
14
|
// developer writes to. `@xeplr/factory` runs them; the designer lists them.
|
|
15
15
|
|
|
16
|
+
import { nodeFunctions, paramProblems } from './buttons.js'
|
|
17
|
+
|
|
16
18
|
export const SCREEN_EVENTS = [
|
|
17
19
|
{
|
|
18
20
|
key: 'beforeLoad',
|
|
@@ -83,6 +85,25 @@ export function missingFunctions(doc, registered) {
|
|
|
83
85
|
})
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* EVERYTHING THAT STOPS A SCREEN RUNNING ITS FUNCTIONS — its events, and the
|
|
90
|
+
* functions its controls name (a list's rows, a column's hover box, each
|
|
91
|
+
* button), plus each button's parameters against what its function takes.
|
|
92
|
+
* `registered` is the registry's list ([{ name, event, inputSchema }]).
|
|
93
|
+
* What publishing refuses on. Empty when the screen can run.
|
|
94
|
+
*/
|
|
95
|
+
export function functionProblems(doc, registered) {
|
|
96
|
+
const byName = new Map((registered || []).map((f) => [f.name, f]))
|
|
97
|
+
const fromNodes = nodeFunctions(doc).flatMap(({ event, name, at, button }) => {
|
|
98
|
+
const f = byName.get(name)
|
|
99
|
+
if (!f) return [{ event, name, at, problem: `"${name}" is not a registered function` }]
|
|
100
|
+
if (f.event !== event) return [{ event, name, at, problem: `"${name}" is written for ${f.event ? `"${f.event}"` : 'a workflow step'}, not "${event}"` }]
|
|
101
|
+
if (!button) return []
|
|
102
|
+
return paramProblems(button, f.inputSchema).map((p) => ({ event, name, at, problem: `button "${button.label || button.id}": "${name}" ${p}` }))
|
|
103
|
+
})
|
|
104
|
+
return [...missingFunctions(doc, registered), ...fromNodes]
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/** The screen with one event set to a function — or cleared, given nothing. */
|
|
87
108
|
export function setEvent(doc, key, name) {
|
|
88
109
|
if (!SCREEN_EVENT_KEYS.includes(key)) throw new Error(`"${key}" is not an event — one of: ${SCREEN_EVENT_KEYS.join(', ')}`)
|
package/src/model.js
CHANGED
|
@@ -13,7 +13,14 @@ export {
|
|
|
13
13
|
} from './document.js'
|
|
14
14
|
export { validateDocument, assertValidDocument } from './validateDocument.js'
|
|
15
15
|
// A screen's events: functions picked by name, run on the server.
|
|
16
|
-
export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, setEvent, functionsFor } from './events.js'
|
|
16
|
+
export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
|
|
17
|
+
// Buttons and lists whose rows a function works out — names and values only.
|
|
18
|
+
export {
|
|
19
|
+
BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, CONDITION_OPERATORS,
|
|
20
|
+
conditionMet, buttonShown, buttonDisabled, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
|
|
21
|
+
} from './buttons.js'
|
|
22
|
+
// Which pages are drawn from documents and which are web only (ui/pages.json).
|
|
23
|
+
export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
|
|
17
24
|
export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
|
|
18
25
|
export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
|
|
19
26
|
export { scaffoldEntity } from './scaffold.js'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// WHICH PAGES WORK EVERYWHERE, AND WHICH ARE WEB ONLY.
|
|
2
|
+
//
|
|
3
|
+
// A page drawn from screen documents (a form's list, a flow's screens, a list
|
|
4
|
+
// fed by a function, a panel of buttons) can be drawn by anything that reads
|
|
5
|
+
// a document — a phone app included. A page written by hand in React cannot:
|
|
6
|
+
// it is the browser's alone.
|
|
7
|
+
//
|
|
8
|
+
// Writing one is allowed. Doing it without anyone knowing is what this file
|
|
9
|
+
// is for. Each app keeps an inventory, ui/pages.json:
|
|
10
|
+
//
|
|
11
|
+
// {
|
|
12
|
+
// "routes": ["src/App.jsx"],
|
|
13
|
+
// "documents": [{ "path": "/orders", "menu": "Orders", "screen": "orders_board" }],
|
|
14
|
+
// "webOnly": [{ "path": "/home", "menu": "Home", "reason": "The dashboard's cards are hand-written" }]
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// and `xeplr-factory pages` WARNS — it never refuses — about every web-only
|
|
18
|
+
// page, and about every route the inventory does not mention. The menu key
|
|
19
|
+
// lets a settings screen tag that menu item "Web only".
|
|
20
|
+
//
|
|
21
|
+
// Pure: no React, no file system. The CLI reads the files.
|
|
22
|
+
|
|
23
|
+
/** Whatever is wrong with the inventory itself, as [{ path, message }]. */
|
|
24
|
+
export function checkInventory(inv) {
|
|
25
|
+
const errors = []
|
|
26
|
+
const err = (path, message) => errors.push({ path, message })
|
|
27
|
+
if (!inv || typeof inv !== 'object' || Array.isArray(inv)) { err('', 'must be an object: { routes, documents, webOnly }'); return errors }
|
|
28
|
+
if (inv.routes !== undefined && !(Array.isArray(inv.routes) && inv.routes.every((r) => typeof r === 'string' && r))) err('routes', 'must be the files that declare the routes, e.g. ["src/App.jsx"]')
|
|
29
|
+
;['documents', 'webOnly'].forEach((list) => {
|
|
30
|
+
if (inv[list] === undefined) return
|
|
31
|
+
if (!Array.isArray(inv[list])) { err(list, 'must be an array of { path, … }'); return }
|
|
32
|
+
inv[list].forEach((p, i) => {
|
|
33
|
+
const at = `${list}[${i}]`
|
|
34
|
+
if (!p || typeof p !== 'object') { err(at, 'must be { path, … }'); return }
|
|
35
|
+
if (typeof p.path !== 'string' || !p.path.startsWith('/')) err(`${at}.path`, 'must be a route path, e.g. "/orders/:orderId"')
|
|
36
|
+
if (p.menu !== undefined && (typeof p.menu !== 'string' || !p.menu)) err(`${at}.menu`, 'must be the menu key it is under, e.g. "Orders"')
|
|
37
|
+
if (list === 'webOnly' && !(typeof p.reason === 'string' && p.reason.trim())) err(`${at}.reason`, 'is required — say why it is hand-written, so the next person knows what it would take to make it a document')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
const seen = new Map()
|
|
41
|
+
;[...(inv.documents || []), ...(inv.webOnly || [])].forEach((p) => {
|
|
42
|
+
if (!p || typeof p.path !== 'string') return
|
|
43
|
+
if (seen.has(p.path)) err('', `"${p.path}" is listed twice`)
|
|
44
|
+
seen.set(p.path, true)
|
|
45
|
+
})
|
|
46
|
+
return errors
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The route paths a routes file declares: every <Route path="…">, except
|
|
51
|
+
* redirects (an element that is a <Navigate>) and the catch-all "*".
|
|
52
|
+
*/
|
|
53
|
+
export function routesIn(source) {
|
|
54
|
+
const out = []
|
|
55
|
+
const re = /<Route\b[^>]*?\bpath=(?:"([^"]+)"|'([^']+)'|\{\s*["'`]([^"'`]+)["'`]\s*\})[^>]*>/g
|
|
56
|
+
let m
|
|
57
|
+
while ((m = re.exec(String(source || '')))) {
|
|
58
|
+
const path = m[1] || m[2] || m[3]
|
|
59
|
+
if (!path || path === '*') continue
|
|
60
|
+
if (/element=\{\s*<Navigate\b/.test(m[0])) continue
|
|
61
|
+
out.push(path)
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(out)]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The warnings for an app: each web-only page, and each route nobody
|
|
68
|
+
* listed. [{ kind: 'webOnly' | 'unlisted' | 'gone' | 'inventory', path, message }]
|
|
69
|
+
*/
|
|
70
|
+
export function pageWarnings(inv, routes) {
|
|
71
|
+
const warnings = checkInventory(inv).map((e) => ({ kind: 'inventory', path: e.path, message: `pages.json ${e.path ? e.path + ' ' : ''}${e.message}` }))
|
|
72
|
+
const docs = new Set(((inv && inv.documents) || []).map((p) => p && p.path))
|
|
73
|
+
const web = ((inv && inv.webOnly) || []).filter((p) => p && p.path)
|
|
74
|
+
web.forEach((p) => warnings.push({ kind: 'webOnly', path: p.path, message: `${p.path} is web only — it will not be in a phone app. ${p.reason || ''}`.trim() }))
|
|
75
|
+
const listed = new Set([...docs, ...web.map((p) => p.path)])
|
|
76
|
+
;(routes || []).forEach((r) => {
|
|
77
|
+
if (!listed.has(r)) warnings.push({ kind: 'unlisted', path: r, message: `${r} is not in pages.json — say whether it is drawn from documents ("documents") or hand-written ("webOnly", with the reason)` })
|
|
78
|
+
})
|
|
79
|
+
// A listed page the routes no longer have: the inventory is out of date.
|
|
80
|
+
if (routes) listed.forEach((p) => { if (!routes.includes(p)) warnings.push({ kind: 'gone', path: p, message: `${p} is in pages.json but no route declares it any more — remove it` }) })
|
|
81
|
+
return warnings
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** { menu key → why } for the menu items whose page is web only — a settings screen tags them. */
|
|
85
|
+
export function webOnlyMenus(inv) {
|
|
86
|
+
const out = {}
|
|
87
|
+
;((inv && inv.webOnly) || []).forEach((p) => {
|
|
88
|
+
if (p && p.menu && !out[p.menu]) out[p.menu] = p.reason || 'Hand-written for the browser'
|
|
89
|
+
})
|
|
90
|
+
return out
|
|
91
|
+
}
|
package/src/remote.js
CHANGED
|
@@ -83,6 +83,10 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
const one = (rows) => (Array.isArray(rows) ? rows[0] : rows)
|
|
85
85
|
const enc = encodeURIComponent
|
|
86
|
+
const query = (params) => {
|
|
87
|
+
const pairs = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
88
|
+
return pairs.length ? '?' + pairs.map(([k, v]) => `${enc(k)}=${enc(v)}`).join('&') : ''
|
|
89
|
+
}
|
|
86
90
|
|
|
87
91
|
const api = {
|
|
88
92
|
// screens
|
|
@@ -123,6 +127,16 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
123
127
|
onDelete: ({ id, screen }) => call('POST', `/records/${enc(screen)}/delete`, { id }),
|
|
124
128
|
fetchOptions: ({ table }) => call('GET', `/options/${enc(table)}`),
|
|
125
129
|
|
|
130
|
+
// ROWS A FUNCTION WORKS OUT, for a list with rowsFrom: { rows, counts }.
|
|
131
|
+
// Only what the person typed goes with it — the function is the screen's.
|
|
132
|
+
fetchRows: async ({ screen, node, q, filter }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter })}`)),
|
|
133
|
+
// An activity cell's hover box for one row.
|
|
134
|
+
fetchRowDetails: ({ screen, node, column, id }) => call('GET', `/rows/${enc(screen)}/details${query({ node, column, id })}`),
|
|
135
|
+
// A button's press on one record → { button, message, output }. The
|
|
136
|
+
// server reads the button — its function and parameters — from the
|
|
137
|
+
// published screen; the request names it and carries the note.
|
|
138
|
+
pressButton: async ({ screen, button, id, note }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, note ? { id, note } : { id })),
|
|
139
|
+
|
|
126
140
|
// A file field's upload. The answer's `path` is what the record stores;
|
|
127
141
|
// the file itself is read back through the same route, with auth.
|
|
128
142
|
uploadFile: async (file, { screen, field }) => {
|
|
@@ -132,11 +146,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
|
|
|
132
146
|
},
|
|
133
147
|
fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
|
|
134
148
|
}
|
|
135
|
-
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl
|
|
149
|
+
api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl,
|
|
150
|
+
fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton }
|
|
136
151
|
// makeChildTable: a list on a form can make the table it is about to point
|
|
137
152
|
// at — rows belonging to this record — without leaving the designer. A
|
|
138
153
|
// server act, which is why the builder is given it rather than doing it.
|
|
139
|
-
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
|
|
154
|
+
api.builderProps = { onSave: api.saveDraft, onPublish: api.publish, listTables: api.listTables, listFunctions: api.listFunctions, fetchOptions: api.fetchOptions, fetchRecords: api.fetchRecords, makeChildTable: function (spec) { return createChildTable(api, spec) },
|
|
140
155
|
// The builder designs a child table in place, which needs the same calls
|
|
141
156
|
// this api already makes — handed over rather than rebuilt.
|
|
142
157
|
screenApi: api }
|
package/src/urlState.js
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The query keys under a host's `urlKey`, or null (in memory).
|
|
15
|
-
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab' }
|
|
15
|
+
* factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab', q: 'formQ', filter: 'formFilter' }
|
|
16
16
|
*/
|
|
17
17
|
export function factoryUrlKeys(urlKey) {
|
|
18
18
|
if (!urlKey) return null
|
|
19
|
-
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab` }
|
|
19
|
+
return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab`, q: `${urlKey}Q`, filter: `${urlKey}Filter` }
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
package/src/useFactoryBuilder.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useFunctionCatalog } from './useFunctionCatalog.js'
|
|
2
3
|
import { CONTROLS, controlGroups } from './controls.js'
|
|
3
4
|
import {
|
|
4
5
|
createScreen, renameScreen, setScreenProperty, addControl as addControlTo, moveNode, setNodeProperty, setNodeStep,
|
|
@@ -28,6 +29,9 @@ import { selectionPlace } from './urlState.js'
|
|
|
28
29
|
* @param onSave async (document) → void; called automatically, only with a valid document
|
|
29
30
|
* @param autosaveDelay ms of quiet after the last edit before saving (default 800)
|
|
30
31
|
* @param onChange (document) → void; every edit, for hosts that autosave or preview
|
|
32
|
+
* @param listFunctions async () → [{ name, event, description, inputSchema }] — the app's
|
|
33
|
+
* registered functions, offered to a list's "Rows from", a column's
|
|
34
|
+
* hover box and each button (with the parameters it takes)
|
|
31
35
|
* @param listTables async () → [{ id, name }] | string[]; offered in a dropdown's
|
|
32
36
|
* "from a table" picker. Omitted → table sources can still be typed.
|
|
33
37
|
* @param screens [{ id, name, document? }] — other screens, offered for a list's "Edit in";
|
|
@@ -57,7 +61,7 @@ function linkNameFor(doc) {
|
|
|
57
61
|
return singular.replace(/_([a-z])/g, function (_, c) { return c.toUpperCase() }) + 'Id'
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
64
|
+
export function useFactoryBuilder({ document: given, name, onSave, onChange, onPublish, listTables, listFunctions, makeChildTable, screens, lockedNames, controls = CONTROLS, autosaveDelay = 800, urlKey } = {}) {
|
|
61
65
|
const [doc, setDoc] = useState(() => given || createScreen({ name }))
|
|
62
66
|
|
|
63
67
|
// ── selection ─────────────────────────────────────────────────────────
|
|
@@ -139,6 +143,9 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
139
143
|
return () => { cancelled = true }
|
|
140
144
|
}, [hasListTables, tableRound])
|
|
141
145
|
|
|
146
|
+
// ── the app's functions, for what a list's rows and a button run ──────
|
|
147
|
+
const functions = useFunctionCatalog({ listFunctions })
|
|
148
|
+
|
|
142
149
|
// ── edits ─────────────────────────────────────────────────────────────
|
|
143
150
|
const rename = useCallback((value) => update((d) => renameScreen(d, value)), [update])
|
|
144
151
|
/** A screen property by path — `source`, `width`, `style.fontSize`. */
|
|
@@ -485,6 +492,7 @@ export function useFactoryBuilder({ document: given, name, onSave, onChange, onP
|
|
|
485
492
|
removeSelected,
|
|
486
493
|
duplicateSelected,
|
|
487
494
|
tables,
|
|
495
|
+
functions,
|
|
488
496
|
screens: screens || [],
|
|
489
497
|
lockedNames: lockedNames || [],
|
|
490
498
|
validation,
|