@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.
- package/AUTHORING.md +5 -1
- package/README.md +97 -13
- package/package.json +1 -1
- package/src/buttons.js +105 -3
- package/src/controls.js +52 -4
- package/src/designs/ButtonsView.jsx +69 -6
- package/src/designs/ControlView.jsx +26 -44
- package/src/designs/FedList.jsx +70 -4
- package/src/designs/FileField.jsx +99 -0
- package/src/designs/PropertyPanel.jsx +127 -3
- package/src/designs/ScreenSample.jsx +1 -0
- package/src/designs/factory.css +81 -10
- package/src/listRange.js +85 -0
- package/src/model.js +6 -4
- package/src/pages.jsx +7 -0
- package/src/remote.js +22 -7
- package/src/tableSchema.js +4 -2
- package/src/urlState.js +2 -2
- package/src/useFactoryScreen.js +47 -12
- package/src/useFilePreview.js +40 -0
- package/src/validateDocument.js +112 -12
- package/src/values.js +27 -8
|
@@ -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
|
|
321
|
-
*
|
|
322
|
-
* the
|
|
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
|
|
326
|
-
const [
|
|
327
|
-
const
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
}
|
package/src/designs/FedList.jsx
CHANGED
|
@@ -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:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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),
|
package/src/designs/factory.css
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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-
|
|
168
|
-
.xeplr-factory-
|
|
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-
|
|
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-
|
|
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; }
|