@xeplr/ui-factory 1.8.5 → 1.8.7

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.
@@ -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
  }
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { XeplrTable } from '@xeplr/ui-table'
4
4
  import { boxStyle, fieldStyle } from './styles.js'
5
+ import { DATE_PRESETS, customRange, isDay } from '../listRange.js'
5
6
 
6
7
  // A LIST WHOSE ROWS A FUNCTION WORKS OUT (rowsFrom) — live, in @xeplr/ui-table.
7
8
  //
@@ -11,9 +12,10 @@ import { boxStyle, fieldStyle } from './styles.js'
11
12
  // renderer. What this file draws is only how: a second line, badges, the
12
13
  // pictures, an activity line with its hover box.
13
14
  //
14
- // Above the rows: the search box and the filter chips, each chip with the
15
- // count the function gave it. Presentation only — the query, the rows and
16
- // what a click does arrive as props.
15
+ // Above the rows: a date range and KPI cards (a dashboard), then the search
16
+ // box and the filter chips, each card and chip with the count the function
17
+ // gave it. A card is a filter too: a click shows its rows, another lets go.
18
+ // Presentation only — the query, the rows and what a click does arrive as props.
17
19
 
18
20
  const OPEN_DELAY_MS = 250
19
21
  const CLOSE_DELAY_MS = 200
@@ -66,8 +68,37 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
66
68
  if (marker) open(rows[Number(marker.getAttribute('data-xf-row'))])
67
69
  }
68
70
 
71
+ const kpis = Array.isArray(p.kpis) ? p.kpis : []
72
+ const kpiCounts = list.kpis || {}
73
+ const range = query.rangeFor ? query.rangeFor(node) : null
74
+
69
75
  return (
70
76
  <div className="xeplr-factory-list xeplr-factory-list--fed" style={style}>
77
+ {(range || kpis.length > 0) && (
78
+ <div className="xeplr-factory-dash">
79
+ {range && <RangePicker label={p.dateRange.label} range={range} onChange={query.setDates} />}
80
+ {kpis.length > 0 && (
81
+ <div className="xeplr-factory-kpis" role="group" aria-label={p.title ? `${p.title} at a glance` : 'At a glance'}>
82
+ {kpis.map((k) => {
83
+ const on = query.kpi === k.key
84
+ const n = kpiCounts[k.key]
85
+ return (
86
+ <button
87
+ key={k.key}
88
+ type="button"
89
+ aria-pressed={on}
90
+ className={`xeplr-factory-kpi xeplr-factory-kpi--${k.tone || 'accent'}${on ? ' is-on' : ''}`}
91
+ onClick={() => query.setKpi(on ? '' : k.key)}
92
+ >
93
+ <span className="xeplr-factory-kpi-count">{n === undefined ? '—' : n}</span>
94
+ <span className="xeplr-factory-kpi-label">{k.label}</span>
95
+ </button>
96
+ )
97
+ })}
98
+ </div>
99
+ )}
100
+ </div>
101
+ )}
71
102
  <div className="xeplr-factory-list-tools">
72
103
  {p.title && <span className="xeplr-factory-list-title">{p.title}</span>}
73
104
  {p.search && p.search.placeholder && (
@@ -102,7 +133,7 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
102
133
  {list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
103
134
  </div>
104
135
  {rows.length === 0
105
- ? !list.loading && !list.error && <div className="xeplr-factory-list-empty">{query.q ? 'Nothing matches that search.' : 'Nothing here yet.'}</div>
136
+ ? !list.loading && !list.error && <div className="xeplr-factory-list-empty">{query.q || query.kpi || (range && range.from) ? 'Nothing matches.' : 'Nothing here yet.'}</div>
106
137
  : (
107
138
  <div className={'xeplr-factory-fed-table' + (open ? ' is-openable' : '')} onClick={openFromRow}>
108
139
  <XeplrTable
@@ -121,6 +152,41 @@ export default function FedList({ node, list, query, onOpen, rowDetails, recordK
121
152
  )
122
153
  }
123
154
 
155
+ /** From–To: a preset, or two days typed. Whatever is chosen is kept as one value. */
156
+ function RangePicker({ label, range, onChange }) {
157
+ const custom = !range.preset
158
+ const [from, setFrom] = useState(range.from)
159
+ const [to, setTo] = useState(range.to)
160
+ useEffect(() => { setFrom(range.from); setTo(range.to) }, [range.from, range.to])
161
+ const typed = (f, t) => {
162
+ setFrom(f); setTo(t)
163
+ if ((f === '' || isDay(f)) && (t === '' || isDay(t)) && !(f && t && f > t)) onChange(customRange(f, t))
164
+ }
165
+ return (
166
+ <div className="xeplr-factory-range" role="group" aria-label={label ? `${label} between` : 'Dates'}>
167
+ {label && <span className="xeplr-factory-range-label">{label}</span>}
168
+ <select
169
+ className="xeplr-factory-range-preset"
170
+ aria-label={label ? `${label}: range` : 'Range'}
171
+ value={custom ? 'custom' : range.preset}
172
+ onChange={(e) => onChange(e.target.value === 'custom' ? customRange(range.from, range.to) : e.target.value)}
173
+ >
174
+ {DATE_PRESETS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
175
+ <option value="custom">Between…</option>
176
+ </select>
177
+ {(custom || range.from || range.to) && (
178
+ <span className="xeplr-factory-range-days">
179
+ <input type="date" className="xeplr-factory-range-day" aria-label="From" value={from} max={to || undefined}
180
+ onChange={(e) => typed(e.target.value, to)} />
181
+ <span aria-hidden="true">–</span>
182
+ <input type="date" className="xeplr-factory-range-day" aria-label="To" value={to} min={from || undefined}
183
+ onChange={(e) => typed(from, e.target.value)} />
184
+ </span>
185
+ )}
186
+ </div>
187
+ )
188
+ }
189
+
124
190
  /** One cell: the server's text, and how this column draws it. */
125
191
  function Cell({ column: c, row, index, first, open, details, onOpenAt }) {
126
192
  const value = row[c.field]
@@ -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
+ }
@@ -3,8 +3,8 @@ import { getAtPath } from '../propertyPath.js'
3
3
  import { FIELD_PRESETS, presetFor } from '../presets.js'
4
4
  import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
5
5
  import { isBinding, previousBinding } from '../values.js'
6
- import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
7
- import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, optionValues } from '../buttons.js'
6
+ import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS, FILE_SOURCES, MAX_FILES } from '../controls.js'
7
+ import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, FILES_INPUT, optionValues } from '../buttons.js'
8
8
 
9
9
  // The selected control's properties, GENERATED from its entry in controls.js:
10
10
  // each field there names a path and an editor type, and this file owns only
@@ -644,6 +644,26 @@ function ToggleEditor({ id, field, value, onChange }) {
644
644
  )
645
645
  }
646
646
 
647
+ /** A list's date range: none, or where it starts and what the dates are. */
648
+ function DateRangeEditor({ id, field, value, onChange }) {
649
+ const r = value && typeof value === 'object' ? value : null
650
+ return (
651
+ <div className="xeplr-factory-prop-stack">
652
+ <select id={id} className="xeplr-factory-prop-input" value={r ? (r.default || 'all') : ''} onChange={(e) => {
653
+ const v = e.target.value
654
+ onChange(v === '' ? undefined : { ...(r || {}), default: v })
655
+ }}>
656
+ <option value="">— none —</option>
657
+ {(field.presets || []).map((o) => <option key={o.value} value={o.value}>Starts at: {o.label}</option>)}
658
+ </select>
659
+ {r && (
660
+ <input className="xeplr-factory-prop-input" type="text" value={r.label || ''} placeholder='What the dates are, e.g. "Placed"'
661
+ onChange={(e) => { const next = { ...r, label: e.target.value }; if (!next.label) delete next.label; onChange(next) }} />
662
+ )}
663
+ </div>
664
+ )
665
+ }
666
+
647
667
  function SelectEditor({ id, field, value, onChange }) {
648
668
  // Style selects may be unset ("inherit"); numeric option values stay numbers.
649
669
  const optional = String(field.path || '').includes('.style.') || String(field.path || '').startsWith('style.')
@@ -941,6 +961,7 @@ function ButtonsEditor({ value, onChange, functions }) {
941
961
  const fn = pick.find((f) => f.name === b.action) || null
942
962
  const schema = (fn && fn.inputSchema) || []
943
963
  const takesNote = schema.some((f) => f.name === NOTE_INPUT)
964
+ const takesFiles = schema.some((f) => f.name === FILES_INPUT)
944
965
  return (
945
966
  <fieldset key={i} className="xeplr-factory-rows-editor-card">
946
967
  <legend>{b.label || `Button ${i + 1}`}</legend>
@@ -961,7 +982,7 @@ function ButtonsEditor({ value, onChange, functions }) {
961
982
  </select>
962
983
  </MiniField>
963
984
  {fn && fn.description && <div className="xeplr-factory-prop-help">{fn.description}</div>}
964
- {schema.filter((f) => f.name !== NOTE_INPUT).map((f) => (
985
+ {schema.filter((f) => f.name !== NOTE_INPUT && f.name !== FILES_INPUT).map((f) => (
965
986
  <MiniField key={f.name} label={(f.label || f.name) + (f.required && f.default === undefined ? ' *' : '')} help={f.description}>
966
987
  <ParamInput field={f} value={b.params ? b.params[f.name] : undefined} onChange={(v) => {
967
988
  const params = { ...(b.params || {}) }
@@ -981,6 +1002,14 @@ function ButtonsEditor({ value, onChange, functions }) {
981
1002
  {takesNote
982
1003
  ? <MiniField label="Note box label" help="Blank → no note box"><input className="xeplr-factory-prop-input" value={b.ask.note || ''} placeholder="Reason (optional)" onChange={(e) => edit(i, { ask: clean({ ...b.ask, note: e.target.value }) })} /></MiniField>
983
1004
  : fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
1005
+ <AskFieldsEditor
1006
+ value={b.ask.fields}
1007
+ inputs={schema.filter((f) => f.name !== NOTE_INPUT && f.name !== FILES_INPUT && !(b.params && Object.prototype.hasOwnProperty.call(b.params, f.name)))}
1008
+ onChange={(fields) => edit(i, { ask: clean({ ...b.ask, fields }) })}
1009
+ />
1010
+ {takesFiles
1011
+ ? <AskFileEditor value={b.ask.file} onChange={(file) => edit(i, { ask: clean({ ...b.ask, file }) })} />
1012
+ : b.ask.file && <div className="xeplr-factory-prop-help">{fn ? fn.name : 'This function'} takes no files — the picker will be refused when publishing.</div>}
984
1013
  </>
985
1014
  )}
986
1015
  <ConditionsEditor label="Shows when" value={b.when} onChange={(v) => edit(i, { when: v })} />
@@ -1001,6 +1030,100 @@ function ButtonsEditor({ value, onChange, functions }) {
1001
1030
 
1002
1031
  const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
1003
1032
 
1033
+ /**
1034
+ * The values a button's press asks for (`ask.fields`) — a parcel's size before
1035
+ * Packed. Each is one of the function's inputs the screen does not give.
1036
+ */
1037
+ function AskFieldsEditor({ value, inputs, onChange }) {
1038
+ const fields = Array.isArray(value) ? value : []
1039
+ const set = (next) => onChange(next.length ? next : undefined)
1040
+ const edit = (j, patch) => set(fields.map((f, k) => {
1041
+ if (k !== j) return f
1042
+ const next = { ...f, ...patch }
1043
+ Object.keys(next).forEach((key) => { if (next[key] === undefined || next[key] === '' || next[key] === false) delete next[key] })
1044
+ return next
1045
+ }))
1046
+ const free = inputs.filter((f) => !fields.some((a) => a.name === f.name))
1047
+ const typeOf = (f) => (f && f.type === 'number' ? 'number' : 'text')
1048
+ if (!inputs.length && !fields.length) return null
1049
+ return (
1050
+ <div className="xeplr-factory-rows-editor">
1051
+ <div className="xeplr-factory-prop-label">Asks for</div>
1052
+ {fields.map((f, j) => (
1053
+ <fieldset key={f.name || j} className="xeplr-factory-rows-editor-card">
1054
+ <legend>{f.label || f.name}</legend>
1055
+ <RowTools i={j} count={fields.length} onMove={(to) => set(move(fields, j, to))} onRemove={() => set(fields.filter((_, k) => k !== j))} />
1056
+ <MiniField label="Input"><input className="xeplr-factory-prop-input" value={f.name} readOnly /></MiniField>
1057
+ <MiniField label="Label"><input className="xeplr-factory-prop-input" value={f.label || ''} onChange={(e) => edit(j, { label: e.target.value })} /></MiniField>
1058
+ <MiniField label="Unit" help='e.g. "in", "kg"'><input className="xeplr-factory-prop-input" value={f.unit || ''} maxLength={12} onChange={(e) => edit(j, { unit: e.target.value })} /></MiniField>
1059
+ {f.type === 'number' && (
1060
+ <MiniField label="At least"><input className="xeplr-factory-prop-input" type="number" value={f.min ?? ''} onChange={(e) => edit(j, { min: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
1061
+ )}
1062
+ <MiniField label="Starts from" help="A field of the record — asked again, it shows the last value"><input className="xeplr-factory-prop-input" value={f.fill || ''} onChange={(e) => edit(j, { fill: e.target.value })} /></MiniField>
1063
+ <label className="xeplr-factory-toggle">
1064
+ <input type="checkbox" checked={Boolean(f.required)} onChange={(e) => edit(j, { required: e.target.checked })} />
1065
+ <span>Required</span>
1066
+ </label>
1067
+ </fieldset>
1068
+ ))}
1069
+ {free.length > 0 && (
1070
+ <select className="xeplr-factory-prop-input" value="" aria-label="Ask for another input" onChange={(e) => {
1071
+ const f = free.find((x) => x.name === e.target.value)
1072
+ if (f) set([...fields, { name: f.name, label: f.label || f.name, type: typeOf(f) }])
1073
+ }}>
1074
+ <option value="">+ Ask for…</option>
1075
+ {free.map((f) => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
1076
+ </select>
1077
+ )}
1078
+ </div>
1079
+ )
1080
+ }
1081
+
1082
+ /**
1083
+ * The file a button's press takes (`ask.file`): a file field's rules, set the
1084
+ * same way. Offered only when the function takes "files".
1085
+ */
1086
+ function AskFileEditor({ value, onChange }) {
1087
+ const f = value && typeof value === 'object' ? value : null
1088
+ const set = (patch) => {
1089
+ const next = { ...f, ...patch }
1090
+ Object.keys(next).forEach((k) => { if (next[k] === undefined || next[k] === '' || next[k] === false) delete next[k] })
1091
+ if (!next.multiple) delete next.maxFiles
1092
+ onChange(next)
1093
+ }
1094
+ return (
1095
+ <>
1096
+ <label className="xeplr-factory-toggle">
1097
+ <input type="checkbox" checked={Boolean(f)} onChange={(e) => onChange(e.target.checked ? { label: 'Photo', accept: FILE_KINDS.image.accept } : undefined)} />
1098
+ <span>Asks for a file</span>
1099
+ </label>
1100
+ {f && (
1101
+ <>
1102
+ <MiniField label="File box label"><input className="xeplr-factory-prop-input" value={f.label || ''} onChange={(e) => set({ label: e.target.value })} /></MiniField>
1103
+ <MiniField label="Allowed types"><AcceptEditor value={f.accept} onChange={(accept) => set({ accept })} /></MiniField>
1104
+ <MiniField label="Take from">
1105
+ <select className="xeplr-factory-prop-input" value={f.source || 'any'} onChange={(e) => set({ source: e.target.value === 'any' ? undefined : e.target.value })}>
1106
+ {FILE_SOURCES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
1107
+ </select>
1108
+ </MiniField>
1109
+ <MiniField label="Largest size (MB)"><input className="xeplr-factory-prop-input" type="number" min={1} max={200} value={f.maxSize ?? ''} onChange={(e) => set({ maxSize: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
1110
+ <label className="xeplr-factory-toggle">
1111
+ <input type="checkbox" checked={Boolean(f.multiple)} onChange={(e) => set({ multiple: e.target.checked })} />
1112
+ <span>Several files</span>
1113
+ </label>
1114
+ {f.multiple && (
1115
+ <MiniField label="At most (files)"><input className="xeplr-factory-prop-input" type="number" min={1} max={MAX_FILES} value={f.maxFiles ?? ''} onChange={(e) => set({ maxFiles: e.target.value === '' ? undefined : Number(e.target.value) })} /></MiniField>
1116
+ )}
1117
+ <label className="xeplr-factory-toggle">
1118
+ <input type="checkbox" checked={Boolean(f.required)} onChange={(e) => set({ required: e.target.checked })} />
1119
+ <span>Required</span>
1120
+ </label>
1121
+ </>
1122
+ )}
1123
+ </>
1124
+ )
1125
+ }
1126
+
1004
1127
  /** One parameter's value: a dropdown when the function names the values allowed. */
1005
1128
  function ParamInput({ field, value, onChange }) {
1006
1129
  const allowed = optionValues(field.options)
@@ -1119,6 +1242,7 @@ export const EDITORS = {
1119
1242
  toggle: ToggleEditor,
1120
1243
  toggleValue: ToggleValueEditor,
1121
1244
  select: SelectEditor,
1245
+ dateRange: DateRangeEditor,
1122
1246
  font: FontEditor,
1123
1247
  color: ColorEditor,
1124
1248
  table: TableEditor,
@@ -220,6 +220,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
220
220
  error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
221
221
  options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
222
222
  upload={node.type === 'file' ? ctrl.upload : undefined}
223
+ readFile={node.type === 'file' ? ctrl.readFile : undefined}
223
224
  rows={node.type === 'table' && ctrl.rowsFor ? ctrl.rowsFor(node) : undefined}
224
225
  stepper={node.type === 'stepper' ? {
225
226
  active: ctrl.steps.active(node.id),
@@ -157,15 +157,40 @@
157
157
  .xeplr-factory-choice { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); cursor: pointer; color: var(--xf-ink); }
158
158
  .xeplr-factory-choice input { width: calc(16 * var(--xf-u)); height: calc(16 * var(--xf-u)); min-width: 11px; min-height: 11px; margin: 0; accent-color: var(--xf-accent); }
159
159
 
160
- /* ── file ─────────────────────────────────────────────────────────────────── */
161
- .xeplr-factory-file { display: flex; align-items: center; gap: calc(8 * var(--xf-u)); min-width: 0; }
162
- .xeplr-factory-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
163
- .xeplr-factory-file-clear {
160
+ /* ── file ───────────────────────────────────────────────────────────────────
161
+ What the field holds — a row per file, a thumbnail for a picture — and,
162
+ while it has room, the picker: a box that looks like the other inputs, the
163
+ real <input type="file"> laid over it, invisible, so a tap opens the
164
+ device's own picker (on a phone: Photos, Camera, Files). */
165
+ .xeplr-factory-files { display: flex; flex-direction: column; gap: calc(6 * var(--xf-u, 1px)); min-width: 0; min-height: 0; overflow: auto; }
166
+ .xeplr-factory-files-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: calc(4 * var(--xf-u, 1px)); }
167
+ .xeplr-factory-files-item { display: flex; align-items: center; gap: calc(8 * var(--xf-u, 1px)); min-width: 0; }
168
+ .xeplr-factory-files-thumb {
169
+ flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
170
+ object-fit: cover; border-radius: calc(4 * var(--xf-u, 1px)); border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
171
+ }
172
+ .xeplr-factory-files-icon {
173
+ flex: none; width: calc(40 * var(--xf-u, 1px)); height: calc(40 * var(--xf-u, 1px)); min-width: 28px; min-height: 28px;
174
+ display: inline-flex; align-items: center; justify-content: center; border-radius: calc(4 * var(--xf-u, 1px));
175
+ border: 1px solid var(--xf-field-border); background: var(--xf-field-bg);
176
+ }
177
+ .xeplr-factory-files-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--xf-ink); }
178
+ .xeplr-factory-files-remove {
164
179
  flex: none; border: 1px solid var(--xf-field-border); background: var(--xf-field-bg); color: var(--xf-muted);
165
- border-radius: calc(6 * var(--xf-u)); padding: calc(3 * var(--xf-u)) calc(8 * var(--xf-u)); cursor: pointer; font: inherit;
180
+ border-radius: calc(6 * var(--xf-u, 1px)); padding: calc(3 * var(--xf-u, 1px)) calc(8 * var(--xf-u, 1px)); cursor: pointer; font: inherit;
166
181
  }
167
- .xeplr-factory-file-clear:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
168
- .xeplr-factory-file-input { padding: calc(5 * var(--xf-u)); }
182
+ .xeplr-factory-files-remove:hover { color: var(--xf-ink); border-color: var(--xf-accent); }
183
+ .xeplr-factory-files-add {
184
+ position: relative; display: flex; align-items: center; justify-content: center; flex: none;
185
+ min-height: calc(34 * var(--xf-u, 1px)); padding: calc(5 * var(--xf-u, 1px)) calc(10 * var(--xf-u, 1px)); box-sizing: border-box;
186
+ border: 1px dashed var(--xf-field-border); border-radius: calc(6 * var(--xf-u, 1px)); background: var(--xf-field-bg);
187
+ color: var(--xf-ink); cursor: pointer;
188
+ }
189
+ .xeplr-factory-files-add:hover, .xeplr-factory-files-add:focus-within { border-color: var(--xf-accent); }
190
+ .xeplr-factory-files-add--off { cursor: default; opacity: .6; }
191
+ .xeplr-factory-files-input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: inherit; margin: 0; }
192
+ .xeplr-factory-files-add-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
193
+ .xeplr-factory-files-error { color: var(--xf-danger); font-size: .9em; white-space: normal; }
169
194
 
170
195
  /* ── stepper ──────────────────────────────────────────────────────────────
171
196
  The journey across the top: a circle per step, joined by a line. The line
@@ -298,6 +323,34 @@
298
323
  Search and chips over the rows; every cell is text the server wrote. */
299
324
  .xeplr-factory-list--fed { overflow: hidden; }
300
325
  .xeplr-factory-list--fed .xeplr-table-container { border: none; border-radius: 0; background: transparent; }
326
+ /* A dashboard over the rows: the date range, then a card per KPI — a count,
327
+ and a filter (pressed: its rows). */
328
+ .xeplr-factory-dash { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-bottom: 1px solid var(--xf-border); }
329
+ .xeplr-factory-range { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--xf-ink); }
330
+ .xeplr-factory-range-label { font-weight: 600; }
331
+ .xeplr-factory-range-days { display: inline-flex; align-items: center; gap: 6px; color: var(--xf-muted); }
332
+ .xeplr-factory-range-preset, .xeplr-factory-range-day {
333
+ font: inherit; color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
334
+ border-radius: 8px; padding: 6px 10px; min-height: 36px; box-sizing: border-box;
335
+ }
336
+ .xeplr-factory-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
337
+ .xeplr-factory-kpi {
338
+ --xf-kpi: var(--xf-accent);
339
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; cursor: pointer;
340
+ font: inherit; color: var(--xf-ink); background: var(--xf-field-bg);
341
+ border: 1px solid var(--xf-border); border-left: 4px solid var(--xf-kpi); border-radius: 10px; padding: 12px 14px;
342
+ }
343
+ .xeplr-factory-kpi:hover { background: var(--xf-hover); }
344
+ .xeplr-factory-kpi:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-kpi) 35%, transparent); }
345
+ .xeplr-factory-kpi.is-on { background: color-mix(in srgb, var(--xf-kpi) 12%, var(--xf-field-bg)); border-color: var(--xf-kpi); }
346
+ .xeplr-factory-kpi--success { --xf-kpi: var(--xf-good); }
347
+ .xeplr-factory-kpi--warning { --xf-kpi: var(--xf-warn); }
348
+ .xeplr-factory-kpi--danger { --xf-kpi: var(--xf-danger); }
349
+ .xeplr-factory-kpi--muted { --xf-kpi: var(--xf-muted); }
350
+ .xeplr-factory-kpi-count { font-size: 1.9em; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
351
+ .xeplr-factory-kpi-label { color: var(--xf-muted); font-size: .92em; }
352
+ .xeplr-factory-kpi.is-on .xeplr-factory-kpi-label { color: var(--xf-ink); }
353
+
301
354
  .xeplr-factory-list-tools {
302
355
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px;
303
356
  border-bottom: 1px solid var(--xf-border);
@@ -392,6 +445,15 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
392
445
  color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
393
446
  }
394
447
  .xeplr-factory-ask-note { resize: vertical; min-height: 80px; }
448
+ .xeplr-factory-ask-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px 12px; }
449
+ .xeplr-factory-ask-value { display: flex; align-items: center; gap: 6px; }
450
+ .xeplr-factory-ask-input {
451
+ flex: 1 1 auto; min-width: 0; font: inherit; color: var(--xf-ink); background: var(--xf-field-bg);
452
+ border: 1px solid var(--xf-field-border); border-radius: 8px; padding: 7px 10px; box-sizing: border-box;
453
+ }
454
+ .xeplr-factory-ask-input[aria-invalid] { border-color: var(--xf-danger); }
455
+ .xeplr-factory-ask-unit { color: var(--xf-muted); white-space: nowrap; min-width: 1.6em; }
456
+ .xeplr-factory-ask-field-error { color: var(--xf-danger); font-size: .88em; }
395
457
  .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); }
396
458
  .xeplr-factory-ask-error { margin: 0; color: var(--xf-danger); }
397
459
  .xeplr-factory-ask-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; }
@@ -804,7 +866,9 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
804
866
  the smallest target a thumb hits reliably. */
805
867
  .xeplr-factory-input { --xf-min-font: 16px; font-size: max(16px, 1em); flex: none; min-height: 44px; }
806
868
  .xeplr-factory-textarea { min-height: 112px; }
807
- .xeplr-factory-file-input { min-height: 0; }
869
+ .xeplr-factory-files { overflow: visible; }
870
+ .xeplr-factory-files-add { min-height: 44px; }
871
+ .xeplr-factory-files-thumb, .xeplr-factory-files-icon { width: 48px; height: 48px; }
808
872
  /* In the flow now: a message may push the next field down, and wraps
809
873
  rather than being cut off at the edge of a narrow screen. */
810
874
  .xeplr-factory-error { position: static; white-space: normal; }
@@ -812,7 +876,7 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
812
876
  .xeplr-factory-check input { width: 22px; height: 22px; }
813
877
  .xeplr-factory-choice input { width: 20px; height: 20px; }
814
878
  .xeplr-factory-choices { overflow: visible; }
815
- .xeplr-factory-file-clear { min-height: 40px; padding-inline: 14px; }
879
+ .xeplr-factory-files-remove { min-height: 40px; padding-inline: 14px; }
816
880
  .xeplr-factory-text { height: auto; overflow: visible; }
817
881
  .xeplr-factory-stepper { height: auto; }
818
882
  .xeplr-factory-list { max-height: none; }
@@ -836,11 +900,18 @@ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:foc
836
900
  /* Function-fed lists and buttons on a phone: the search takes the width, chips
837
901
  scroll in a row, and every button is a thumb-sized target. */
838
902
  @media (max-width: 640px) {
839
- .xeplr-factory-list-search, .xeplr-factory-ask-note { font-size: max(16px, 1em); min-height: 44px; }
903
+ .xeplr-factory-list-search, .xeplr-factory-ask-note, .xeplr-factory-ask-input { font-size: max(16px, 1em); min-height: 44px; }
904
+ .xeplr-factory-ask-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
840
905
  .xeplr-factory-list-search { flex-basis: 100%; max-width: none; }
841
906
  .xeplr-factory-ask-foot button { flex: 1; min-height: 44px; }
842
907
  .xeplr-factory-chips { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; padding-bottom: 2px; }
843
908
  .xeplr-factory-chip { min-height: 36px; }
909
+ .xeplr-factory-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
910
+ .xeplr-factory-range-preset, .xeplr-factory-range-day { font-size: max(16px, 1em); min-height: 44px; }
911
+ .xeplr-factory-range-preset { flex: 1 1 100%; }
912
+ .xeplr-factory-range-days { flex: 1 1 100%; min-width: 0; }
913
+ /* A date box's own width is the browser's; let the row decide instead. */
914
+ .xeplr-factory-range-day { flex: 1 1 0; min-width: 0; width: 0; }
844
915
  .xeplr-factory-button { min-height: 44px; flex: 1 1 auto; }
845
916
  .xeplr-factory-buttons-note { flex-basis: 100%; }
846
917
  .xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 16px 8px; }