@xeplr/ui-factory 1.8.4 → 1.8.6

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 CHANGED
@@ -241,7 +241,7 @@ A button is **a registered function and its parameters** — never code. Several
241
241
  ] } }
242
242
  ```
243
243
 
244
- Register the function with `event: 'button'` and an `inputSchema` (with `options` for the values allowed, and a `note` input if a button asks for one). `when` / `disabledWhen` are `@xeplr/rules` rules on the record. Publishing refuses parameters the function does not take. See the README's "Buttons".
244
+ Register the function with `event: 'button'` and an `inputSchema` (with `options` for the values allowed, a `note` input if a button asks for one, and a `files` input if its `ask.file` takes photos or documents — `{ "accept": ".png,.jpg,.jpeg", "multiple": true, "source": "any" }`, the same rules as a file field). `when` / `disabledWhen` are `@xeplr/rules` rules on the record. Publishing refuses parameters the function does not take. See the README's "Buttons".
245
245
 
246
246
  ## When the document cannot say it
247
247
 
@@ -292,6 +292,8 @@ Rules you give merge over the ready-made ones (`"validation": { "max": 120 }`).
292
292
  | one of the app's records | `dropdown` with `table` |
293
293
  | several of a set — tags, skills, days | `multiselect` |
294
294
  | a document, a contract, a spreadsheet, a photo | `file` with `accept` (`".pdf,.docx"`) |
295
+ | several photos, several documents | `file` with `multiple: true` (and `maxFiles`) — the value is a list of paths |
296
+ | a photo taken now, at the door / on site | `file` with `source: "camera"` and images in `accept` — the phone opens its camera |
295
297
  | a section title between groups | `{ "type": "label", "text": "…", "variant": "subheading" }` |
296
298
  | a long form people work through in stages | a `stepper`, then `step` on each field |
297
299
 
package/README.md CHANGED
@@ -103,6 +103,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
103
103
  onDelete={async ({ id, source }) => api.remove(source, id)}
104
104
  fetchOptions={async ({ table }) => api.options(table)}
105
105
  uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)} // a file field: → { path }
106
+ readFile={async (path) => api.fileBlob(path)} // optional: a stored picture's bytes, for its thumbnail
106
107
  screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
107
108
  />
108
109
  ```
@@ -115,7 +116,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
115
116
 
116
117
  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.
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
+ 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** (a field with `multiple`: an **array** of paths).
119
120
 
120
121
  ### On a phone
121
122
 
@@ -143,7 +144,10 @@ one scalar have a rule of their own:
143
144
  the server use them, so it is written once.
144
145
  * **File** — the column holds the path the upload answered with, not the file.
145
146
  The file itself is stored by `@xeplr/factory` and read back through its
146
- authenticated route; nothing is ever served without a check.
147
+ authenticated route; nothing is ever served without a check. A field with
148
+ `multiple` keeps its paths in one `text` column, joined by commas, like a
149
+ multi-select (a stored path has no comma in it); turning `multiple` on is a
150
+ widening, so a path already saved reads back as a list of one.
147
151
  * **Date and time** — a `timestamp` column, read back as text so no timezone can
148
152
  move the moment. A `date` column may be widened to `timestamp` on publish
149
153
  (the day becomes midnight); the reverse is refused, as it would lose the time.
@@ -433,6 +437,7 @@ actions.register({
433
437
  | `ask` | asked first: `title`, `message`, and `note` — the label of a note box, whose text reaches the function as `note` (it must take one) |
434
438
  | `when` | shown only when every rule matches the record |
435
439
  | `disabledWhen` | greyed out when every rule matches |
440
+ | `pressedWhen` | drawn as the **current choice** (filled, `aria-pressed`) when every rule matches — the status an order is at. Give it the same rules as `disabledWhen` when choosing it again would change nothing |
436
441
  | `variant` | `default`, `primary`, `danger` |
437
442
 
438
443
  Rules are [`@xeplr/rules`](https://www.npmjs.com/package/@xeplr/rules) rules — `{ field, op, value }`, with `eq`, `neq`, `isEmpty`, `contains`, `gt`, … — read from **the record the screen is about**: the `record` handed to `<FactoryScreen>`, with the form's own changes on top.
@@ -442,6 +447,52 @@ Rules are [`@xeplr/rules`](https://www.npmjs.com/package/@xeplr/rules) rules —
442
447
  - **What happens next is the host's.** The panel shows the function's `message`; `onButtonDone({ button, result, record })` lets the page reload what it shows. Lists on the same screen reload by themselves.
443
448
  - **In the builder**, picking a function lists the parameters it takes — a dropdown where it names the values allowed — so nobody types a status by hand. Functions come from `listFunctions` (`createFactoryApi`'s `builderProps` has it).
444
449
 
450
+ ### A button that takes files — `ask.file`
451
+
452
+ A press can carry photos or documents: the button's `ask` gets a file picker,
453
+ with a file field's own rules.
454
+
455
+ ```json
456
+ { "id": "proof", "label": "Delivered", "action": "markDelivered", "params": { "status": "delivered" },
457
+ "ask": { "title": "Proof of delivery", "note": "Note (optional)",
458
+ "file": { "label": "Photos", "accept": ".png,.jpg,.jpeg", "maxSize": 10, "multiple": true, "maxFiles": 5, "source": "any", "required": true } } }
459
+ ```
460
+
461
+ ```js
462
+ actions.register({
463
+ name: 'markDelivered', event: 'button',
464
+ inputSchema: [{ name: 'status', type: 'string', required: true }, { name: 'files', type: 'array' }, { name: 'note', type: 'string' }],
465
+ execute: async ({ input, system }) => {
466
+ // input.files: the stored paths. system.files: [{ path, name, size, fullPath }] — read the bytes from fullPath.
467
+ }
468
+ })
469
+ ```
470
+
471
+ - **Each file is stored before the press** — `POST /factory/buttons/:screen/:button/files`, checked against `ask.file`'s `accept` and `maxSize` — and the press sends the stored paths as `files`.
472
+ - **The server takes only files stored for this button, by this person, in this company** — a path typed by hand, or another button's, is refused. It also checks `required`, `multiple` and `maxFiles` again.
473
+ - `accept` is required: the server must have something to check an upload against.
474
+ - The function must take `files` (in its `inputSchema`); publishing refuses a button whose function does not. The builder shows the file settings only for a function that does.
475
+ - Host: pass `uploadButtonFile` (`createFactoryApi`'s `screenProps` has it).
476
+
477
+ ## Files: gallery, camera, several
478
+
479
+ A file field — and a button's `ask.file` — says **what** it wants; the device
480
+ decides how to get it. The same document works in a desktop browser, a phone
481
+ browser, an installed web app and, later, a phone app.
482
+
483
+ | prop | |
484
+ |---|---|
485
+ | `accept` | the extensions it takes — the server refuses anything else |
486
+ | `maxSize` | megabytes, per file |
487
+ | `source` | `any` (default): the device's own picker — on a phone, one menu of **Camera, Photos and Files**. `camera`: the camera opens straight away, for a photo taken now (`capture="environment"`); `accept` must then be images only |
488
+ | `multiple` | holds several files — the value is an array of paths |
489
+ | `maxFiles` | with `multiple`: at most this many (up to 20) |
490
+
491
+ - **Pictures show as thumbnails.** An `<img>` cannot send the sign-in token, so a stored picture is read back through `readFile(path) → Blob` and shown from an object URL. `createFactoryApi({ fetch: authFetch, fetchFile: authFetchFile })` (both from `@xeplr/ui-account`) gives `screenProps.readFile`; without it a field shows names only.
492
+ - **Several files**: chosen together, they upload one by one and each is added as it lands, so a refusal of the third keeps the first two. Remove takes one away.
493
+ - `filePicker(props)` → `{ accept, multiple, capture }` is the one place that turns these props into a picker — a phone app's renderer reads the same.
494
+ - iPhone photos are HEIC. iOS hands a photo over as JPEG when the picker does not ask for HEIC, so `.heic` is not in the Images list — not yet checked on a real iPhone.
495
+
445
496
  ## A list whose rows a function works out — `rowsFrom`
446
497
 
447
498
  A list reads one table's records. When the rows are **worked out** — an
@@ -785,7 +836,7 @@ Both walk someone through something, and they answer different questions:
785
836
  | `dropdown` | option id | — |
786
837
  | `radio` | option id | — |
787
838
  | `multiselect` | array of option ids | `minItems`, `maxItems` |
788
- | `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
839
+ | `file` | the stored file's path — or, with `multiple`, an array of them | `accept` (extensions), `maxSize` (MB), `multiple`, `maxFiles`, `source` (`any` / `camera`); see [Files: gallery, camera, several](#files-gallery-camera-several) |
789
840
  | `stepper` | — | a journey across the top; every other control says which step it is on |
790
841
  | `label` | — | text with a heading, subheading or text preset |
791
842
  | `list` | — | saved records, with New / Edit / Delete — or rows a function works out (`rowsFrom`), with search, filter chips and counts; see above |
@@ -1029,7 +1080,7 @@ src/
1029
1080
  hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
1030
1081
  presets.js ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
1031
1082
  tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
1032
- 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 })
1083
+ remote.js ─ createFactoryApi({ fetch, fetchFile }): 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 })
1033
1084
  screenSource.js ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
1034
1085
  model.js ─ everything above, React-free
1035
1086
  useFactoryBuilder.js ─ builder controller
@@ -1040,10 +1091,11 @@ src/
1040
1091
  urlState.js ─ the URL keys and values for selection and steps (React-free)
1041
1092
  useUrlPlaces.js ─ useUrlSteps, useUrlSelection: those places, in the URL or in memory
1042
1093
  useScreenSource.js ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
1043
- designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, Palette, PropertyPanel, styles.js, factory.css
1094
+ useFilePreview.js ─ a stored picture as an object URL, through readFile
1095
+ designs/ ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, FedList, ButtonsView, FileField, Palette, PropertyPanel, styles.js, factory.css
1044
1096
  pages.jsx ─ FactoryBuilder, FactoryScreen
1045
1097
  bin/xeplr-factory.js ─ screens / generate / validate / controls / schema / pages
1046
- examples/ ─ employee.entity.json and what `screens` makes of it; orders-board and order-actions (a function-fed list, buttons)
1098
+ examples/ ─ employee.entity.json and what `screens` makes of it; orders-board and order-actions (a function-fed list, buttons — one takes photos); delivery-photos (several pictures, camera only)
1047
1099
  dev/ ─ the stand-in app for `npm run dev` (not published)
1048
1100
  ```
1049
1101
 
@@ -1053,7 +1105,7 @@ dev/ ─ the stand-in app for `npm run dev` (not published)
1053
1105
  npm test
1054
1106
  ```
1055
1107
 
1056
- `test/buttons.test.js` covers buttons and function-fed lists in the document: what the checker refuses, when a button shows or is greyed out, what a press sends (the document's parameters, never the request's), and `functionProblems` against a registry. `test/cli.test.js` includes `pages`.
1108
+ `test/buttons.test.js` covers buttons and function-fed lists in the document: what the checker refuses, when a button shows or is greyed out, what a press sends (the document's parameters, never the request's), and `functionProblems` against a registry. `test/cli.test.js` includes `pages`. `test/files.test.js` covers files: several in one field (the value, its column, its checks), camera only, and a button whose press takes files.
1057
1109
 
1058
1110
  `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`.
1059
1111
 
@@ -1062,7 +1114,7 @@ npx playwright install chromium-headless-shell # once
1062
1114
  npm run test:phone
1063
1115
  ```
1064
1116
 
1065
- `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. On the Orders page it checks the function-fed list and buttons at phone sizes (16px / 44px search, chips on screen, an "ask first" that fills the screen) and, at 1280px, that they work: search and chips narrow the rows and the counts, the hover box lists entries, a row opens, a press with a note changes the order and greys its button out, and Confirm COD gives way to Undo. 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.
1117
+ `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. On the Orders page it checks the function-fed list and buttons at phone sizes (16px / 44px search, chips on screen, an "ask first" that fills the screen) and, at 1280px, that they work: search and chips narrow the rows and the counts, the hover box lists entries, a row opens, a press with a note changes the order and greys its button out, and Confirm COD gives way to Undo. On the Photos page it checks the pickers (several images; the back camera for camera only), the thumbnails, Remove, and a button whose press takes a photo end to end. 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.
1066
1118
 
1067
1119
  ## License
1068
1120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
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/buttons.js CHANGED
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // { id: 'packed', label: 'Packed', action: 'setOrderStatus', params: { status: 'packed' },
9
9
  // ask: { title: 'Change status to Packed', note: 'Reason (optional)' },
10
+ // pressedWhen: [{ field: 'customStatus', op: 'eq', value: 'packed' }],
10
11
  // disabledWhen: [{ field: 'customStatus', op: 'eq', value: 'packed' }] }
11
12
  //
12
13
  // The function lives in the app, registered with @xeplr/actions and an
@@ -45,6 +46,13 @@ export const BADGE_TONES = ['accent', 'success', 'warning', 'danger', 'muted']
45
46
  /** The name a button's note reaches its function under. */
46
47
  export const NOTE_INPUT = 'note'
47
48
 
49
+ /**
50
+ * The name a button's files reach its function under: the stored paths, as a
51
+ * list, when the button's `ask.file` takes them. The function reads the bytes
52
+ * through `system.files` (each with its full path on disk).
53
+ */
54
+ export const FILES_INPUT = 'files'
55
+
48
56
  /** Operators a button's condition can use: every one that compares a single value. */
49
57
  export const CONDITION_OPERATORS = OPERATORS.filter((o) => !o.needsRows)
50
58
 
@@ -66,6 +74,16 @@ export function buttonDisabled(button, record) {
66
74
  return Array.isArray(rules) && rules.length > 0 && conditionMet(rules, record)
67
75
  }
68
76
 
77
+ /**
78
+ * Whether a button is drawn as the CURRENT choice — the status an order is
79
+ * at, filled in. Usually also its disabledWhen: choosing it again changes
80
+ * nothing. No pressedWhen: never.
81
+ */
82
+ export function buttonPressed(button, record) {
83
+ const rules = button && button.pressedWhen
84
+ return Array.isArray(rules) && rules.length > 0 && conditionMet(rules, record)
85
+ }
86
+
69
87
  /** Every button on a screen, with the panel it is on. */
70
88
  export function screenButtons(doc) {
71
89
  return ((doc && doc.nodes) || [])
@@ -78,15 +96,37 @@ export function buttonById(doc, id) {
78
96
  return screenButtons(doc).find((b) => b.button && b.button.id === id) || null
79
97
  }
80
98
 
81
- /** What a press sends the function: the document's parameters, and the person's note when it asks for one. */
82
- export function buttonInput(button, note) {
99
+ /** The file a button's press takes (`ask.file`), or null when it takes none. */
100
+ export function askFile(button) {
101
+ const f = button && button.ask && button.ask.file
102
+ return f && typeof f === 'object' ? f : null
103
+ }
104
+
105
+ /**
106
+ * What a press sends the function: the document's parameters, the person's
107
+ * note when it asks for one, and the stored files when it takes them.
108
+ */
109
+ export function buttonInput(button, note, files) {
83
110
  const input = { ...((button && button.params) || {}) }
84
111
  if (button && button.ask && button.ask.note && note !== undefined && note !== null && String(note).trim() !== '') {
85
112
  input[NOTE_INPUT] = String(note).trim()
86
113
  }
114
+ if (askFile(button) && Array.isArray(files) && files.length) input[FILES_INPUT] = files.slice()
87
115
  return input
88
116
  }
89
117
 
118
+ /** Why the files given to a press are not what its `ask.file` takes — or null. */
119
+ export function askFileProblem(button, files) {
120
+ const f = askFile(button)
121
+ const list = Array.isArray(files) ? files : []
122
+ if (!f) return list.length ? 'This button takes no files' : null
123
+ const label = f.label || 'A file'
124
+ if (!list.length) return f.required ? `${label} is required` : null
125
+ if (!f.multiple && list.length > 1) return `${label}: one file only`
126
+ if (f.multiple && f.maxFiles && list.length > f.maxFiles) return `${label}: at most ${f.maxFiles} files`
127
+ return null
128
+ }
129
+
90
130
  /**
91
131
  * The functions a screen's CONTROLS name, as [{ event, name, at }] — a list's
92
132
  * rows, a column's hover box, each button. `at` is where in the document, so
@@ -130,9 +170,11 @@ export function paramProblems(button, inputSchema) {
130
170
  schema.forEach((f) => {
131
171
  if (!f || !f.name || !f.required || f.default !== undefined) return
132
172
  if (f.name === NOTE_INPUT && button && button.ask && button.ask.note) return
173
+ if (f.name === FILES_INPUT && askFile(button)) return
133
174
  if (params[f.name] === undefined || params[f.name] === null || params[f.name] === '') problems.push(`needs "${f.name}"`)
134
175
  })
135
176
  if (button && button.ask && button.ask.note && !byName.has(NOTE_INPUT)) problems.push(`asks for a note, but takes no "${NOTE_INPUT}"`)
177
+ if (askFile(button) && !byName.has(FILES_INPUT)) problems.push(`asks for a file, but takes no "${FILES_INPUT}"`)
136
178
  return problems
137
179
  }
138
180
 
package/src/controls.js CHANGED
@@ -139,6 +139,44 @@ export const FILE_KINDS = {
139
139
  any: { label: 'Any of the above', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt,.xls,.xlsx,.csv,.ods,.png,.jpg,.jpeg,.gif,.webp,.svg' }
140
140
  }
141
141
 
142
+ /** The extensions a picture can have — what a preview is drawn for, and all a camera field takes. */
143
+ export const IMAGE_EXTENSIONS = acceptList(FILE_KINDS.image.accept)
144
+
145
+ /**
146
+ * Where a file field's files come from. `any` is the device's own choice —
147
+ * on a phone that is Camera, Photos and Files in one menu; `camera` opens the
148
+ * camera straight away (a photo taken now, not one from the gallery).
149
+ */
150
+ export const FILE_SOURCES = [
151
+ { value: 'any', label: 'Files, gallery or camera' },
152
+ { value: 'camera', label: 'Camera only' }
153
+ ]
154
+
155
+ /** Most files one field can hold. */
156
+ export const MAX_FILES = 20
157
+
158
+ /** Whether a stored path (or a file name) is a picture — by its extension, as the server checks. */
159
+ export function isImageFile(name) {
160
+ const s = String(name || '').toLowerCase()
161
+ const dot = s.lastIndexOf('.')
162
+ return dot !== -1 && IMAGE_EXTENSIONS.includes(s.slice(dot))
163
+ }
164
+
165
+ /**
166
+ * What a file picker is asked for, from a file field's props — the one place
167
+ * that turns `accept`, `multiple` and `source` into a picker. The web reads it
168
+ * as <input type="file"> attributes; a phone app reads the same three.
169
+ */
170
+ export function filePicker(props) {
171
+ const p = props || {}
172
+ const camera = p.source === 'camera'
173
+ return {
174
+ accept: p.accept || '',
175
+ multiple: Boolean(p.multiple) && !camera,
176
+ capture: camera ? 'environment' : undefined
177
+ }
178
+ }
179
+
142
180
  /** An extension list as one string → the same list, cleaned and lower-cased. */
143
181
  export function acceptList(accept) {
144
182
  return String(accept || '')
@@ -456,11 +494,13 @@ export const CONTROLS = {
456
494
  label: 'File upload',
457
495
  group: 'Inputs',
458
496
  input: true,
459
- // The value is the stored file's path, as the upload answered with it.
497
+ // The value is the stored file's path, as the upload answered with it —
498
+ // or, with `multiple`, a list of them (one text column, comma separated,
499
+ // like a multi-select: a stored path has no comma in it).
460
500
  valueType: 'string',
461
501
  defaultSize: { w: 0.44, h: 0.1 },
462
502
  defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
463
- props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'style'],
503
+ props: ['name', 'label', 'preset', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source', 'style'],
464
504
  validation: [],
465
505
  styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
466
506
  properties: [
@@ -470,7 +510,10 @@ export const CONTROLS = {
470
510
  title: 'File',
471
511
  fields: [
472
512
  { path: 'props.accept', label: 'Allowed types', type: 'accept', help: 'Extensions, comma separated — the server refuses anything else' },
473
- { path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 }
513
+ { path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 },
514
+ { path: 'props.source', label: 'Take from', type: 'select', options: FILE_SOURCES, help: 'Camera only: a photo taken now — the allowed types must be images' },
515
+ { path: 'props.multiple', label: 'Several files', type: 'toggle' },
516
+ { path: 'props.maxFiles', label: 'At most (files)', type: 'number', min: 1, max: MAX_FILES, help: 'With several files' }
474
517
  ]
475
518
  },
476
519
  ...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
@@ -1,13 +1,16 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { boxStyle, fieldStyle } from './styles.js'
4
+ import FileField from './FileField.jsx'
4
5
 
5
6
  // A PANEL OF BUTTONS. Each runs a function of the app on the server, with the
6
7
  // parameters the screen gives it; which ones show, and which are greyed out,
7
8
  // the controller works out from the record (useFactoryScreen.buttonsFor).
8
9
  //
9
- // A button that asks first opens a small dialog: its message, and a note box
10
- // when it takes a note. Presentation only — the press is the controller's.
10
+ // A button that asks first opens a small dialog: its message, a note box when
11
+ // it takes a note, and a file picker when it takes files (`ask.file` — on a
12
+ // phone, the gallery or the camera). Presentation only — the press, and the
13
+ // upload, are the controller's.
11
14
  //
12
15
  // On the canvas (design) the buttons are drawn as they will look, inert.
13
16
 
@@ -37,7 +40,8 @@ export default function ButtonsView({ node, design, panel }) {
37
40
  <button
38
41
  key={b.id}
39
42
  type="button"
40
- className={`xeplr-factory-button xeplr-factory-button--${b.variant || 'default'}`}
43
+ className={`xeplr-factory-button xeplr-factory-button--${b.variant || 'default'}${b.pressed ? ' xeplr-factory-button--pressed' : ''}`}
44
+ aria-pressed={b.pressedWhen ? Boolean(b.pressed) : undefined}
41
45
  disabled={b.disabled || Boolean(panel.pressing)}
42
46
  aria-busy={panel.pressing === b.id}
43
47
  onClick={() => run(b)}
@@ -57,17 +61,22 @@ export default function ButtonsView({ node, design, panel }) {
57
61
  busy={panel.pressing === asking.id}
58
62
  error={note && note.kind === 'error' && note.button === asking.id ? note.text : null}
59
63
  onCancel={() => { setAsking(null); panel.clearNote() }}
60
- onConfirm={async (typed) => { if (await panel.press(asking, typed)) setAsking(null) }}
64
+ upload={(file) => panel.upload(file, asking)}
65
+ readFile={panel.readFile}
66
+ onConfirm={async (typed, files) => { if (await panel.press(asking, typed, files)) setAsking(null) }}
61
67
  />
62
68
  )}
63
69
  </div>
64
70
  )
65
71
  }
66
72
 
67
- /** "Change status to Packed?" — the button's message, and a note when it takes one. */
68
- function AskDialog({ button, busy, error, onCancel, onConfirm }) {
73
+ /** "Change status to Packed?" — the button's message, a note and files when it takes them. */
74
+ function AskDialog({ button, busy, error, onCancel, onConfirm, upload, readFile }) {
69
75
  const ask = button.ask || {}
76
+ const file = ask.file && typeof ask.file === 'object' ? ask.file : null
70
77
  const [typed, setTyped] = useState('')
78
+ const [files, setFiles] = useState([])
79
+ const missing = Boolean(file && file.required && !files.length)
71
80
  const ref = useRef(null)
72
81
  useEffect(() => {
73
82
  ref.current?.focus()
@@ -92,11 +101,26 @@ function AskDialog({ button, busy, error, onCancel, onConfirm }) {
92
101
  <textarea id={`${titleId}-note`} className="xeplr-factory-ask-note" rows={3} maxLength={1000} value={typed} onChange={(e) => setTyped(e.target.value)} />
93
102
  </label>
94
103
  )}
104
+ {file && (
105
+ <div className="xeplr-factory-ask-field">
106
+ <span className="xeplr-factory-ask-label" id={`${titleId}-file`}>{file.label || (file.multiple ? 'Files' : 'File')}{file.required ? ' *' : ''}</span>
107
+ <FileField
108
+ id={`${titleId}-files`}
109
+ rules={file}
110
+ files={files}
111
+ onChange={setFiles}
112
+ upload={upload}
113
+ readFile={readFile}
114
+ disabled={busy}
115
+ inputProps={{ 'aria-labelledby': `${titleId}-file`, 'aria-required': file.required ? true : undefined }}
116
+ />
117
+ </div>
118
+ )}
95
119
  {error && <p className="xeplr-factory-ask-error" role="alert">{error}</p>}
96
120
  </div>
97
121
  <footer className="xeplr-factory-ask-foot">
98
122
  <button type="button" className="xeplr-factory-secondary" onClick={onCancel} disabled={busy}>Cancel</button>
99
- <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed)} disabled={busy}>
123
+ <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed, files)} disabled={busy || missing}>
100
124
  {busy ? 'Working…' : button.label}
101
125
  </button>
102
126
  </footer>
@@ -3,6 +3,7 @@ import { tableColumns, LABEL_PRESETS } from '../controls.js'
3
3
  import { stepsOf } from '../document.js'
4
4
  import ListView from './ListView.jsx'
5
5
  import ButtonsView from './ButtonsView.jsx'
6
+ import FileField from './FileField.jsx'
6
7
  import { fieldStyle, labelStyle, boxStyle } from './styles.js'
7
8
 
8
9
  // How each control LOOKS — the one renderer, used both on the builder's
@@ -14,7 +15,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
14
15
 
15
16
  const ids = (node) => `xeplr-factory-${node.id}`
16
17
 
17
- export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform, rows, buttons }) {
18
+ export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, readFile, stepper, subform, rows, buttons }) {
18
19
  const View = VIEWS[node.type]
19
20
  if (!View) {
20
21
  return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
@@ -33,6 +34,7 @@ export default function ControlView({ node, mode = 'live', value, error, options
33
34
  disabled={design || disabled}
34
35
  list={list}
35
36
  upload={upload}
37
+ readFile={readFile}
36
38
  stepper={stepper}
37
39
  subform={subform}
38
40
  rows={rows}
@@ -317,51 +319,31 @@ function DatetimeView({ node, p, value, error, onChange, onBlur, disabled }) {
317
319
  }
318
320
 
319
321
  /**
320
- * A file field holds the stored file's PATH, not the file. Picking one uploads
321
- * it straight away (`upload`, supplied by the screen) and what comes back is
322
- * the value that is saved with the record.
322
+ * A file field holds the stored file's PATH, not the file — or, with
323
+ * `multiple`, a list of them. Picking one uploads it straight away (`upload`,
324
+ * supplied by the screen) and what comes back is the value saved with the
325
+ * record. A stored picture shows as a thumbnail when the screen can read it
326
+ * back (`readFile`).
323
327
  */
324
- function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload }) {
325
- const [busy, setBusy] = useState(false)
326
- const [failed, setFailed] = useState(null)
327
- const accept = p.accept || ''
328
- const choose = async (file) => {
329
- if (!file || !upload) return
330
- setFailed(null)
331
- setBusy(true)
332
- try {
333
- const out = await upload(file, node)
334
- onChange(out && out.path ? out.path : undefined)
335
- } catch (e) {
336
- setFailed(e && e.message ? e.message : 'The file could not be uploaded')
337
- } finally {
338
- setBusy(false)
339
- onBlur()
340
- }
341
- }
328
+ function FileView({ node, p, design, value, error, onChange, onBlur, disabled, upload, readFile }) {
329
+ const several = Boolean(p.multiple)
330
+ const files = several ? (Array.isArray(value) ? value : []) : value ? [value] : []
331
+ const { style, ...aria } = inputProps(node, p, error, disabled, onBlur)
342
332
  return (
343
- <Field node={node} p={p} error={error || failed}>
344
- {value
345
- ? (
346
- <div className="xeplr-factory-file">
347
- <span className="xeplr-factory-file-name" title={String(value)}>{fileName(value)}</span>
348
- <button type="button" className="xeplr-factory-file-clear" disabled={disabled} onClick={() => { onChange(undefined); onBlur() }}>Remove</button>
349
- </div>
350
- )
351
- : (
352
- <input
353
- className="xeplr-factory-input xeplr-factory-file-input"
354
- type="file"
355
- accept={accept}
356
- disabled={disabled || busy}
357
- onChange={(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; choose(f) }}
358
- {...inputProps(node, p, error, disabled || busy, onBlur)}
359
- />
360
- )}
361
- {busy && <div className="xeplr-factory-hint">Uploading…</div>}
362
- {!busy && !value && (design || accept) && (
363
- <div className="xeplr-factory-hint">{accept || 'any file'}{p.maxSize ? ` · up to ${p.maxSize} MB` : ''}</div>
364
- )}
333
+ <Field node={node} p={p} error={error}>
334
+ <FileField
335
+ id={aria.id}
336
+ rules={p}
337
+ files={files}
338
+ onChange={(paths) => onChange(several ? paths : paths[0])}
339
+ onBlur={onBlur}
340
+ upload={upload ? (file) => upload(file, node) : undefined}
341
+ readFile={readFile}
342
+ disabled={disabled}
343
+ design={design}
344
+ inputProps={aria}
345
+ style={style}
346
+ />
365
347
  </Field>
366
348
  )
367
349
  }
@@ -0,0 +1,99 @@
1
+ import { useState } from 'react'
2
+ import { filePicker, isImageFile } from '../controls.js'
3
+ import { fileLabel } from '../values.js'
4
+ import { useFilePreview } from '../useFilePreview.js'
5
+
6
+ // THE FILES A FIELD HOLDS, AND THE PICKER THAT ADDS ONE — a file field's, and
7
+ // a button's `ask.file`. Presentation only: what an upload is, and how a
8
+ // stored picture is read back, are handed in.
9
+ //
10
+ // On a phone the picker is the phone's own: "Files, gallery or camera" opens
11
+ // its menu (Photos, Camera, Files), "Camera only" opens the camera.
12
+
13
+ /**
14
+ * @param rules { accept, maxSize, multiple, maxFiles, source }
15
+ * @param files the stored paths, as a list
16
+ * @param onChange (paths) → void
17
+ * @param upload async (File) → { path }
18
+ * @param readFile async (path) → Blob — a picture's preview; names only without it
19
+ */
20
+ export default function FileField({ id, rules, files, onChange, onBlur, upload, readFile, disabled, design, inputProps, style }) {
21
+ const [busy, setBusy] = useState(false)
22
+ const [failed, setFailed] = useState(null)
23
+ const r = rules || {}
24
+ const picker = filePicker(r)
25
+ const several = Boolean(r.multiple)
26
+ const list = Array.isArray(files) ? files : []
27
+ const full = several ? Boolean(r.maxFiles) && list.length >= r.maxFiles : list.length >= 1
28
+
29
+ // One at a time, in the order chosen: each answer is added as it lands, so a
30
+ // refusal of the third keeps the first two.
31
+ const choose = async (chosen) => {
32
+ if (!chosen.length || !upload) return
33
+ setFailed(null)
34
+ setBusy(true)
35
+ let next = list
36
+ try {
37
+ const room = several ? (r.maxFiles ? Math.max(r.maxFiles - list.length, 0) : chosen.length) : 1
38
+ if (chosen.length > room) setFailed(`At most ${r.maxFiles} files — the first ${room} were added`)
39
+ for (const file of chosen.slice(0, room)) {
40
+ const out = await upload(file)
41
+ if (out && out.path) { next = several ? [...next, out.path] : [out.path]; onChange(next) }
42
+ }
43
+ } catch (e) {
44
+ setFailed(e && e.message ? e.message : 'The file could not be uploaded')
45
+ } finally {
46
+ setBusy(false)
47
+ if (onBlur) onBlur()
48
+ }
49
+ }
50
+
51
+ const remove = (path) => { onChange(list.filter((p) => p !== path)); if (onBlur) onBlur() }
52
+ const hint = [r.accept || 'any file', r.maxSize ? `up to ${r.maxSize} MB` : null, several && r.maxFiles ? `at most ${r.maxFiles} files` : null].filter(Boolean).join(' · ')
53
+
54
+ return (
55
+ <div className="xeplr-factory-files">
56
+ {list.length > 0 && (
57
+ <ul className="xeplr-factory-files-list">
58
+ {list.map((path) => (
59
+ <FileItem key={path} path={path} readFile={readFile} disabled={disabled || busy} onRemove={() => remove(path)} />
60
+ ))}
61
+ </ul>
62
+ )}
63
+ {!full && (
64
+ <label className={`xeplr-factory-files-add${disabled || busy ? ' xeplr-factory-files-add--off' : ''}`} style={style}>
65
+ <input
66
+ className="xeplr-factory-files-input"
67
+ type="file"
68
+ accept={picker.accept || undefined}
69
+ multiple={picker.multiple || undefined}
70
+ capture={picker.capture}
71
+ disabled={disabled || busy}
72
+ onChange={(e) => { const chosen = Array.from(e.target.files || []); e.target.value = ''; choose(chosen) }}
73
+ {...(inputProps || {})}
74
+ id={id}
75
+ />
76
+ <span className="xeplr-factory-files-add-label">
77
+ {busy ? 'Uploading…' : r.source === 'camera' ? (list.length ? 'Take another photo' : 'Take a photo') : list.length ? 'Add another' : several ? 'Choose files' : 'Choose a file'}
78
+ </span>
79
+ </label>
80
+ )}
81
+ {failed && <div className="xeplr-factory-files-error" role="alert">{failed}</div>}
82
+ {!busy && !list.length && (design || r.accept) && <div className="xeplr-factory-hint">{hint}</div>}
83
+ </div>
84
+ )
85
+ }
86
+
87
+ function FileItem({ path, readFile, disabled, onRemove }) {
88
+ const preview = useFilePreview(path, readFile)
89
+ const picture = isImageFile(path)
90
+ return (
91
+ <li className="xeplr-factory-files-item">
92
+ {picture && preview.url
93
+ ? <img className="xeplr-factory-files-thumb" src={preview.url} alt="" />
94
+ : <span className="xeplr-factory-files-icon" aria-hidden="true">{picture ? '🖼' : '📄'}</span>}
95
+ <span className="xeplr-factory-files-name" title={fileLabel(path)}>{fileLabel(path)}</span>
96
+ <button type="button" className="xeplr-factory-files-remove" disabled={disabled} onClick={onRemove} aria-label={`Remove ${fileLabel(path)}`}>Remove</button>
97
+ </li>
98
+ )
99
+ }