@xeplr/ui-factory 1.8.3 → 1.8.5

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.
@@ -0,0 +1,108 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { boxStyle, fieldStyle } from './styles.js'
4
+
5
+ // A PANEL OF BUTTONS. Each runs a function of the app on the server, with the
6
+ // parameters the screen gives it; which ones show, and which are greyed out,
7
+ // the controller works out from the record (useFactoryScreen.buttonsFor).
8
+ //
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.
11
+ //
12
+ // On the canvas (design) the buttons are drawn as they will look, inert.
13
+
14
+ export default function ButtonsView({ node, design, panel }) {
15
+ const p = node.props || {}
16
+ const style = { ...fieldStyle(p.style), ...boxStyle(p.style) }
17
+ const [asking, setAsking] = useState(null) // the button whose dialog is open
18
+
19
+ if (design || !panel) {
20
+ const all = p.buttons || []
21
+ return (
22
+ <div className="xeplr-factory-buttons xeplr-factory-buttons--design" style={style}>
23
+ {p.title && <span className="xeplr-factory-buttons-title">{p.title}</span>}
24
+ {all.length === 0 && <span className="xeplr-factory-hint">No buttons yet — add them in Properties.</span>}
25
+ {all.map((b) => <span key={b.id} className={`xeplr-factory-button xeplr-factory-button--${b.variant || 'default'}`}>{b.label}</span>)}
26
+ </div>
27
+ )
28
+ }
29
+
30
+ const run = (b) => (b.ask ? setAsking(b) : panel.press(b))
31
+ const note = panel.note
32
+
33
+ return (
34
+ <div className="xeplr-factory-buttons" style={style} role="group" aria-label={p.title || 'Actions'}>
35
+ {p.title && <span className="xeplr-factory-buttons-title">{p.title}</span>}
36
+ {panel.buttons.map((b) => (
37
+ <button
38
+ key={b.id}
39
+ type="button"
40
+ className={`xeplr-factory-button xeplr-factory-button--${b.variant || 'default'}${b.pressed ? ' xeplr-factory-button--pressed' : ''}`}
41
+ aria-pressed={b.pressedWhen ? Boolean(b.pressed) : undefined}
42
+ disabled={b.disabled || Boolean(panel.pressing)}
43
+ aria-busy={panel.pressing === b.id}
44
+ onClick={() => run(b)}
45
+ >
46
+ {panel.pressing === b.id ? `${b.label}…` : b.label}
47
+ </button>
48
+ ))}
49
+ {note && !asking && (
50
+ <span className={`xeplr-factory-buttons-note xeplr-factory-buttons-note--${note.kind}`} role={note.kind === 'error' ? 'alert' : 'status'}>
51
+ {note.text}
52
+ <button type="button" className="xeplr-factory-buttons-note-close" aria-label="Dismiss" onClick={panel.clearNote}>×</button>
53
+ </span>
54
+ )}
55
+ {asking && (
56
+ <AskDialog
57
+ button={asking}
58
+ busy={panel.pressing === asking.id}
59
+ error={note && note.kind === 'error' && note.button === asking.id ? note.text : null}
60
+ onCancel={() => { setAsking(null); panel.clearNote() }}
61
+ onConfirm={async (typed) => { if (await panel.press(asking, typed)) setAsking(null) }}
62
+ />
63
+ )}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ /** "Change status to Packed?" — the button's message, and a note when it takes one. */
69
+ function AskDialog({ button, busy, error, onCancel, onConfirm }) {
70
+ const ask = button.ask || {}
71
+ const [typed, setTyped] = useState('')
72
+ const ref = useRef(null)
73
+ useEffect(() => {
74
+ ref.current?.focus()
75
+ const onKey = (e) => { if (e.key === 'Escape' && !busy) onCancel() }
76
+ window.addEventListener('keydown', onKey)
77
+ return () => window.removeEventListener('keydown', onKey)
78
+ }, [busy, onCancel])
79
+ const titleId = `xf-ask-${button.id}`
80
+ // At the page's root: inside the screen, a control's own box could clip it.
81
+ return createPortal(
82
+ <div className="xeplr-factory-modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onCancel() }}>
83
+ <div ref={ref} className="xeplr-factory-modal xeplr-factory-ask" role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
84
+ <header className="xeplr-factory-modal-head">
85
+ <span className="xeplr-factory-modal-title" id={titleId}>{ask.title || `${button.label}?`}</span>
86
+ <button type="button" className="xeplr-factory-modal-close" onClick={onCancel} disabled={busy} aria-label="Close">×</button>
87
+ </header>
88
+ <div className="xeplr-factory-modal-body">
89
+ {ask.message && <p className="xeplr-factory-ask-message">{ask.message}</p>}
90
+ {ask.note && (
91
+ <label className="xeplr-factory-ask-field" htmlFor={`${titleId}-note`}>
92
+ <span className="xeplr-factory-ask-label">{ask.note}</span>
93
+ <textarea id={`${titleId}-note`} className="xeplr-factory-ask-note" rows={3} maxLength={1000} value={typed} onChange={(e) => setTyped(e.target.value)} />
94
+ </label>
95
+ )}
96
+ {error && <p className="xeplr-factory-ask-error" role="alert">{error}</p>}
97
+ </div>
98
+ <footer className="xeplr-factory-ask-foot">
99
+ <button type="button" className="xeplr-factory-secondary" onClick={onCancel} disabled={busy}>Cancel</button>
100
+ <button type="button" className={button.variant === 'danger' ? 'xeplr-factory-danger' : 'xeplr-factory-primary'} onClick={() => onConfirm(typed)} disabled={busy}>
101
+ {busy ? 'Working…' : button.label}
102
+ </button>
103
+ </footer>
104
+ </div>
105
+ </div>,
106
+ document.body
107
+ )
108
+ }
@@ -2,6 +2,7 @@ import { useState } from 'react'
2
2
  import { tableColumns, LABEL_PRESETS } from '../controls.js'
3
3
  import { stepsOf } from '../document.js'
4
4
  import ListView from './ListView.jsx'
5
+ import ButtonsView from './ButtonsView.jsx'
5
6
  import { fieldStyle, labelStyle, boxStyle } from './styles.js'
6
7
 
7
8
  // How each control LOOKS — the one renderer, used both on the builder's
@@ -13,7 +14,7 @@ import { fieldStyle, labelStyle, boxStyle } from './styles.js'
13
14
 
14
15
  const ids = (node) => `xeplr-factory-${node.id}`
15
16
 
16
- export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform, rows }) {
17
+ export default function ControlView({ node, mode = 'live', value, error, options, onChange, onBlur, disabled, list, upload, stepper, subform, rows, buttons }) {
17
18
  const View = VIEWS[node.type]
18
19
  if (!View) {
19
20
  return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
@@ -35,6 +36,7 @@ export default function ControlView({ node, mode = 'live', value, error, options
35
36
  stepper={stepper}
36
37
  subform={subform}
37
38
  rows={rows}
39
+ buttons={buttons}
38
40
  />
39
41
  )
40
42
  }
@@ -538,6 +540,7 @@ export const VIEWS = {
538
540
  stepper: StepperView,
539
541
  label: LabelView,
540
542
  list: ListControl,
543
+ buttons: ({ node, design, buttons }) => <ButtonsView node={node} design={design} panel={buttons} />,
541
544
  table: TableView,
542
545
  subform: SubformView
543
546
  }
@@ -0,0 +1,248 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { XeplrTable } from '@xeplr/ui-table'
4
+ import { boxStyle, fieldStyle } from './styles.js'
5
+
6
+ // A LIST WHOSE ROWS A FUNCTION WORKS OUT (rowsFrom) — live, in @xeplr/ui-table.
7
+ //
8
+ // Everything it shows is TEXT THE SERVER WROTE: a column sorts by its `field`
9
+ // and shows its `text` field, so "Today, 4:00 pm" or "2d 3h" is said once, by
10
+ // the app that knows its time zone and its words, and reads the same in any
11
+ // renderer. What this file draws is only how: a second line, badges, the
12
+ // pictures, an activity line with its hover box.
13
+ //
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.
17
+
18
+ const OPEN_DELAY_MS = 250
19
+ const CLOSE_DELAY_MS = 200
20
+ const BOX_WIDTH = 380
21
+ const BOX_MAX_HEIGHT = 360
22
+ const MIN_BELOW = 200
23
+
24
+ export default function FedList({ node, list, query, onOpen, rowDetails, recordKey = 'id', extraActions = [] }) {
25
+ const p = node.props || {}
26
+ const columns = Array.isArray(p.columns) ? p.columns : []
27
+ const rows = list.rows || []
28
+ const counts = list.counts || {}
29
+ const chips = Array.isArray(p.filters) ? p.filters : []
30
+ const active = query.filter || (chips[0] && chips[0].key) || ''
31
+ const pageSize = p.pageSize || 10
32
+ const style = { ...fieldStyle(p.style), ...boxStyle(p.style) }
33
+
34
+ // The search box shows what is typed at once; the controller asks after a pause.
35
+ const [typed, setTyped] = useState(query.q)
36
+ useEffect(() => { setTyped(query.q) }, [query.q])
37
+
38
+ const open = p.rowOpen || (p.actions || []).includes('edit') ? onOpen : null
39
+
40
+ const rowActions = []
41
+ if ((p.actions || []).includes('edit')) rowActions.push({ key: 'edit', label: 'Edit', onClick: (row) => onOpen(row) })
42
+ extraActions.forEach((a, i) => rowActions.push({ key: 'extra-' + i + '-' + a.label, label: a.label, variant: a.variant, onClick: (row) => a.onClick(row) }))
43
+
44
+ const schema = {
45
+ 0: {
46
+ key: node.id,
47
+ columns: columns.map((c, i) => ({
48
+ accessor: c.field,
49
+ header: c.label,
50
+ enableSorting: c.sortable !== false,
51
+ cell: ({ row }) => (
52
+ <Cell column={c} row={row.original} index={row.index} first={i === 0} open={open}
53
+ details={c.display === 'activity' && c.details ? () => rowDetails(node, c, row.original[recordKey]) : null}
54
+ onOpenAt={open ? (where) => open(row.original, where) : null} />
55
+ )
56
+ }))
57
+ }
58
+ }
59
+
60
+ // A click anywhere on a row opens it; the first cell is also a real button,
61
+ // for the keyboard. Clicks on the row's own buttons and links are theirs.
62
+ function openFromRow(e) {
63
+ if (!open || e.target.closest('button, a, input, select, .xeplr-factory-activity-box')) return
64
+ const tr = e.target.closest('tbody tr')
65
+ const marker = tr && tr.querySelector('[data-xf-row]')
66
+ if (marker) open(rows[Number(marker.getAttribute('data-xf-row'))])
67
+ }
68
+
69
+ return (
70
+ <div className="xeplr-factory-list xeplr-factory-list--fed" style={style}>
71
+ <div className="xeplr-factory-list-tools">
72
+ {p.title && <span className="xeplr-factory-list-title">{p.title}</span>}
73
+ {p.search && p.search.placeholder && (
74
+ <input
75
+ type="search"
76
+ className="xeplr-factory-list-search"
77
+ aria-label={p.search.placeholder}
78
+ placeholder={p.search.placeholder}
79
+ value={typed}
80
+ maxLength={200}
81
+ onChange={(e) => { setTyped(e.target.value); query.setQ(e.target.value) }}
82
+ />
83
+ )}
84
+ {chips.length > 0 && (
85
+ <div className="xeplr-factory-chips" role="tablist" aria-label={p.title ? `Filter ${p.title}` : 'Filter'}>
86
+ {chips.map((f) => (
87
+ <button
88
+ key={f.key}
89
+ type="button"
90
+ role="tab"
91
+ aria-selected={f.key === active}
92
+ className={'xeplr-factory-chip' + (f.key === active ? ' is-on' : '')}
93
+ onClick={() => query.setFilter(f.key === (chips[0] && chips[0].key) ? '' : f.key)}
94
+ >
95
+ {f.label}
96
+ {counts[f.key] !== undefined && <span className="xeplr-factory-chip-count">{counts[f.key]}</span>}
97
+ </button>
98
+ ))}
99
+ </div>
100
+ )}
101
+ {list.loading && <span className="xeplr-factory-hint">Loading…</span>}
102
+ {list.error && <span className="xeplr-factory-error-inline" role="alert">{list.error}</span>}
103
+ </div>
104
+ {rows.length === 0
105
+ ? !list.loading && !list.error && <div className="xeplr-factory-list-empty">{query.q ? 'Nothing matches that search.' : 'Nothing here yet.'}</div>
106
+ : (
107
+ <div className={'xeplr-factory-fed-table' + (open ? ' is-openable' : '')} onClick={openFromRow}>
108
+ <XeplrTable
109
+ data={rows}
110
+ schema={schema}
111
+ showToolbar={false}
112
+ enableFiltering={false}
113
+ pageSize={pageSize}
114
+ enablePagination={rows.length > pageSize}
115
+ rowActions={rowActions.length ? rowActions : undefined}
116
+ rowClassName={(row) => (p.highlight && row && row[p.highlight] ? 'xeplr-factory-row-marked' : null)}
117
+ />
118
+ </div>
119
+ )}
120
+ </div>
121
+ )
122
+ }
123
+
124
+ /** One cell: the server's text, and how this column draws it. */
125
+ function Cell({ column: c, row, index, first, open, details, onOpenAt }) {
126
+ const value = row[c.field]
127
+ const shown = c.text ? row[c.text] : value
128
+ const badges = (c.badges || []).filter((b) => row[b.field])
129
+ const marker = first ? { 'data-xf-row': index } : {}
130
+
131
+ if (c.display === 'images') {
132
+ const pics = Array.isArray(value) ? value.filter((x) => x && x.src) : []
133
+ const more = c.more ? Number(row[c.more]) || 0 : 0
134
+ return (
135
+ <span className="xeplr-factory-thumbs" {...marker}>
136
+ {pics.map((img, n) => <img key={n} src={img.src} alt={img.name || ''} title={img.name || ''} loading="lazy" />)}
137
+ {more > 0 && <small>+{more}</small>}
138
+ </span>
139
+ )
140
+ }
141
+
142
+ if (c.display === 'activity') {
143
+ return <Activity text={text(shown)} details={details} onOpenAt={onOpenAt} marker={marker} />
144
+ }
145
+
146
+ const body = (
147
+ <>
148
+ <span className="xeplr-factory-cell-main">{text(shown)}</span>
149
+ {badges.map((b) => <span key={b.field} className={`xeplr-factory-badge xeplr-factory-badge--${b.tone || 'accent'}`}>{b.label}</span>)}
150
+ {c.sub && row[c.sub] !== undefined && row[c.sub] !== null && row[c.sub] !== '' && <span className="xeplr-factory-cell-sub">{text(row[c.sub])}</span>}
151
+ </>
152
+ )
153
+ // The first cell of a row that opens is a button: the keyboard's way in.
154
+ if (first && open) {
155
+ return <button type="button" className="xeplr-factory-cell xeplr-factory-cell-open" onClick={(e) => { e.stopPropagation(); open(row) }} {...marker}>{body}</button>
156
+ }
157
+ return <span className="xeplr-factory-cell" {...marker}>{body}</span>
158
+ }
159
+
160
+ function text(v) {
161
+ if (v === undefined || v === null) return ''
162
+ return typeof v === 'object' ? JSON.stringify(v) : String(v)
163
+ }
164
+
165
+ /**
166
+ * WHAT LAST HAPPENED, and a box with the latest few — on hover or focus,
167
+ * loaded the first time and kept until the line changes. Drawn at the page's
168
+ * root so the table's scrolling never clips it; under its cell, or above it
169
+ * near the bottom of the window. A tap on a phone opens the row instead.
170
+ */
171
+ function Activity({ text: line, details, onOpenAt, marker }) {
172
+ const cellRef = useRef(null)
173
+ const timer = useRef(null)
174
+ const [box, setBox] = useState(null) // { top, left, maxHeight, above }
175
+ const [items, setItems] = useState(null) // { line, list, error }
176
+
177
+ useEffect(() => () => clearTimeout(timer.current), [])
178
+ useEffect(() => { setItems(null) }, [line])
179
+
180
+ if (!details) return <span className="xeplr-factory-cell xeplr-factory-activity" {...marker}>{line}</span>
181
+
182
+ function show() {
183
+ clearTimeout(timer.current)
184
+ timer.current = setTimeout(() => {
185
+ const r = cellRef.current && cellRef.current.getBoundingClientRect()
186
+ if (!r) return
187
+ const below = window.innerHeight - r.bottom - 16
188
+ const above = below < MIN_BELOW
189
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - BOX_WIDTH - 8))
190
+ setBox(above
191
+ ? { left, bottom: window.innerHeight - r.top + 8, maxHeight: Math.min(BOX_MAX_HEIGHT, r.top - 16), above }
192
+ : { left, top: r.bottom + 8, maxHeight: Math.min(BOX_MAX_HEIGHT, below), above })
193
+ if (!items || items.line !== line) {
194
+ details().then(
195
+ (list) => setItems({ line, list: Array.isArray(list) ? list : [], error: null }),
196
+ (err) => setItems({ line, list: [], error: err.message || 'Could not load' })
197
+ )
198
+ }
199
+ }, OPEN_DELAY_MS)
200
+ }
201
+ function hide() {
202
+ clearTimeout(timer.current)
203
+ timer.current = setTimeout(() => setBox(null), CLOSE_DELAY_MS)
204
+ }
205
+ // Touch has no hover: a tap is the row's, which opens it.
206
+ const hoverable = typeof window === 'undefined' || !window.matchMedia || !window.matchMedia('(hover: none)').matches
207
+
208
+ return (
209
+ <span
210
+ ref={cellRef}
211
+ className="xeplr-factory-cell xeplr-factory-activity"
212
+ tabIndex={hoverable ? 0 : undefined}
213
+ onMouseEnter={hoverable ? show : undefined}
214
+ onMouseLeave={hoverable ? hide : undefined}
215
+ onFocus={hoverable ? show : undefined}
216
+ onBlur={hoverable ? hide : undefined}
217
+ {...marker}
218
+ >
219
+ {line}
220
+ {box && typeof document !== 'undefined' && createPortal(
221
+ <div
222
+ className="xeplr-factory-activity-box"
223
+ role="dialog"
224
+ aria-label="Latest activity"
225
+ style={{ left: box.left, top: box.top, bottom: box.bottom, maxHeight: box.maxHeight, width: BOX_WIDTH }}
226
+ onMouseEnter={() => clearTimeout(timer.current)}
227
+ onMouseLeave={hide}
228
+ >
229
+ {!items && <div className="xeplr-factory-hint">Loading…</div>}
230
+ {items && items.error && <div className="xeplr-factory-error-inline" role="alert">{items.error}</div>}
231
+ {items && !items.error && !items.list.length && <div className="xeplr-factory-hint">Nothing yet.</div>}
232
+ {items && items.list.map((it, i) => {
233
+ const body = (
234
+ <>
235
+ <span className="xeplr-factory-activity-text">{text(it.text)}</span>
236
+ {(it.sub || it.at) && <span className="xeplr-factory-activity-sub">{[it.sub, it.at].filter(Boolean).map(text).join(' · ')}</span>}
237
+ </>
238
+ )
239
+ return it.open && onOpenAt
240
+ ? <button key={i} type="button" className="xeplr-factory-activity-item" onClick={(e) => { e.stopPropagation(); setBox(null); onOpenAt(it.open) }}>{body}</button>
241
+ : <div key={i} className="xeplr-factory-activity-item">{body}</div>
242
+ })}
243
+ </div>,
244
+ document.body
245
+ )}
246
+ </span>
247
+ )
248
+ }
@@ -1,6 +1,7 @@
1
1
  import { XeplrTable } from '@xeplr/ui-table'
2
2
  import { displayValue, listColumns, listSource } from '../values.js'
3
3
  import { boxStyle, fieldStyle } from './styles.js'
4
+ import FedList from './FedList.jsx'
4
5
 
5
6
  // A list of the saved records, in @xeplr/ui-table.
6
7
  //
@@ -9,8 +10,12 @@ import { boxStyle, fieldStyle } from './styles.js'
9
10
  // Edit and New open the list's edit screen in a popup (`editScreen`), or, for a
10
11
  // list on a form, load the row into the form's own fields. Delete removes it.
11
12
 
12
- export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor, extraActions = [] }) {
13
+ export default function ListView({ node, doc, design, list, onEdit, onDelete, onNew, currentId, recordKey = 'id', canDelete, fieldNodes, optionsFor, extraActions = [], query, rowDetails, onOpen }) {
13
14
  const p = node.props || {}
15
+ // Rows a function works out: their own view — search, chips, the server's text.
16
+ if (!design && p.rowsFrom && list) {
17
+ return <FedList node={node} list={list} query={query || { q: '', filter: '', setQ: () => {}, setFilter: () => {} }} onOpen={onOpen || onEdit} rowDetails={rowDetails} recordKey={recordKey} extraActions={extraActions} />
18
+ }
14
19
  // The rows are passed in because a list of ANOTHER table has no other
15
20
  // description of itself — see listColumns.
16
21
  const columns = listColumns(doc, node, undefined, (list && list.rows) || [])