@arcforgelabs/flow 2026.10.43 → 2026.10.44

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,268 @@
1
+ // A list's columns, as ClickUp's: drag a head's right edge to make the
2
+ // column wider or narrower (never under its kind's minimum), drag a head
3
+ // to move the column, and click or right-click a head for its menu (Move to
4
+ // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
5
+ // and Reconcile build their tables here, so every list works the same.
6
+ //
7
+ // The view a person keeps for a list (with their account, PUT
8
+ // /api/me/views/:page) holds two keys from here:
9
+ // columns the shown columns' ids, in order
10
+ // widths { [id]: px } for the columns the person made wider or narrower
11
+ // The rules (no DOM) are in job-view.js ("Columns"), so the server checks a
12
+ // saved view with them (server/auth/people.js) and tests read them.
13
+ //
14
+ // columnTable(h, config), once per page, answers { table, menu, openMenu }:
15
+ // order() the shown columns' ids, as the view keeps them
16
+ // widths() the saved widths
17
+ // change(patch) saves { columns } or { widths }
18
+ // hidden() [{ id, label }] the + menu lists (no hidden: no +)
19
+ // extra(column, item) menu nodes before Move (Sort, Edit custom field)
20
+ // table({ columns, rows, lead, className }), per draw, answers the table:
21
+ // columns the shown columns, in order: { id, label, kind, cell(row),
22
+ // num?, className?, sort?(): node, icon?, fixed? }. An icon
23
+ // shows in place of the label (the label is its name). A fixed
24
+ // column keeps its place: no drag, no Move, no Hide.
25
+ // rows the <tbody> nodes (rowCells builds a row's cells)
26
+ // lead <th> nodes before the columns (the tick column)
27
+ import { lucide } from '/assets/ui/custom-fields.js';
28
+ import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
29
+
30
+ // The tick column at the start and the + column at the end.
31
+ const LEAD_WIDTH = 44;
32
+ const TAIL_WIDTH = 40;
33
+
34
+ // A row's cells for the shown columns: a class per kind, the label for
35
+ // the phone layout, the full text as the cell's name on hover.
36
+ export function rowCells(h, columns, row) {
37
+ return columns.map((c) => {
38
+ const td = h('td', {
39
+ class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
40
+ 'data-label': c.label,
41
+ }, c.cell(row));
42
+ // A cell stays on one line and ends in "…" when the column is narrow
43
+ // (flow.css, .fx-table--sized); the whole text shows on hover.
44
+ const text = td.textContent?.trim();
45
+ if (text && text !== '—') td.title = text;
46
+ return td;
47
+ });
48
+ }
49
+
50
+ // ── The table ──────────────────────────────────────────────────────────────
51
+
52
+ // One menu for every column head, as a popover: made at the first open and
53
+ // kept for every page after.
54
+ function columnPopover(h) {
55
+ let pop = null;
56
+ const close = () => pop?.hidePopover?.();
57
+ const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
58
+ h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
59
+ const sep = () => h('div', { class: 'fx-pop__sep' });
60
+ // Open with these nodes, at the pointer or under the node clicked.
61
+ function show(parts, event) {
62
+ pop = document.getElementById('fx-colmenu');
63
+ if (!pop) {
64
+ pop = h('div', { class: 'fx-pop', id: 'fx-colmenu', popover: 'auto', role: 'menu' });
65
+ document.body.append(pop);
66
+ }
67
+ pop.replaceChildren(...parts);
68
+ pop.showPopover?.();
69
+ const box = event.currentTarget?.getBoundingClientRect?.();
70
+ const x = event.clientX || box?.left || 0;
71
+ const y = event.clientY || box?.bottom || 0;
72
+ pop.style.top = `${Math.round(Math.min(y + 4, innerHeight - pop.offsetHeight - 8))}px`;
73
+ pop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - pop.offsetWidth - 8)))}px`;
74
+ }
75
+ return { close, item, sep, show };
76
+ }
77
+
78
+ // A popover opened while the right button is still down closes again on
79
+ // its release (light dismiss), so open it after the release.
80
+ export function onRightButton(event, open) {
81
+ if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
82
+ else open();
83
+ }
84
+
85
+ export function columnTable(h, config) {
86
+ const menu = columnPopover(h);
87
+ const ids = () => config.order?.() ?? [];
88
+ const widths = () => config.widths?.() ?? {};
89
+
90
+ function openMenu(column, event) {
91
+ const parts = [];
92
+ if (column) {
93
+ parts.push(...(config.extra?.(column, menu.item) ?? []));
94
+ if (!column.fixed) {
95
+ const order = ids();
96
+ parts.push(menu.item('arrow-left-to-line', 'Move to start', () => config.change({ columns: moveColumn(order, column.id, 0) })),
97
+ menu.item('arrow-right-to-line', 'Move to end', () => config.change({ columns: moveColumn(order, column.id, order.length) })));
98
+ if (config.hidden) parts.push(menu.sep(), menu.item('eye-off', 'Hide column', () => config.change({ columns: order.filter((id) => id !== column.id) })));
99
+ }
100
+ if (widths()[column.id] != null) {
101
+ parts.push(menu.item('move-horizontal', 'Reset width', () => config.change({ widths: withoutKey(widths(), column.id) })));
102
+ }
103
+ } else {
104
+ const hidden = config.hidden?.() ?? [];
105
+ parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { menu.close(); config.change({ columns: [...ids(), c.id] }); } }, c.label)));
106
+ if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
107
+ }
108
+ if (parts.length) menu.show(parts, event);
109
+ }
110
+
111
+ function table({ columns, rows, lead = [], className = '' }) {
112
+ const saved = widths();
113
+ // Each column keeps its own width: the table is as wide as its columns,
114
+ // never stretched to the page. Space left over stays empty at the right.
115
+ // A resize changes the table only; the tools stay put.
116
+ const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
117
+ const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
118
+ const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
119
+ type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
120
+ }, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
121
+ const heads = columns.map((c, n) => head(c, n));
122
+ const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
123
+ h('colgroup', {}, leadCols, cols, h('col', { style: { width: `${TAIL_WIDTH}px` } })),
124
+ h('thead', {}, h('tr', {}, lead, heads, tail)),
125
+ rows);
126
+ // The table is as wide as its columns; past the page it scrolls sideways.
127
+ const fit = () => { node.style.width = `${columns.reduce((sum, c) => sum + widthOf(c, widths()), 0) + lead.length * LEAD_WIDTH + TAIL_WIDTH}px`; };
128
+ fit();
129
+ return h('div', { class: 'fx-table-scroll' }, node);
130
+
131
+ function head(c, n) {
132
+ const button = h('button', {
133
+ type: 'button', class: c.fixed ? 'fx-th fx-th--fixed' : 'fx-th', 'aria-haspopup': 'menu',
134
+ title: c.fixed ? c.label : `${c.label}. Drag to move, click for the menu`,
135
+ onclick: (event) => { if (!dragged) openMenu(c, event); },
136
+ }, c.icon ? lucide(h, c.icon, c.label) : c.label);
137
+ const grip = h('span', {
138
+ class: 'fx-th-grip', role: 'separator', tabindex: '0', 'aria-orientation': 'vertical',
139
+ 'aria-label': `${c.label} width`, title: 'Drag to resize. Double-click to reset',
140
+ 'aria-valuemin': String(minWidth(c.kind)), 'aria-valuemax': String(MAX_WIDTH), 'aria-valuenow': String(widthOf(c, widths())),
141
+ });
142
+ const th = h('th', {
143
+ class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
144
+ 'data-col': c.id,
145
+ oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
146
+ }, h('span', { class: 'fx-th-cell' }, button, c.sort?.() ?? null), grip);
147
+ resizer(grip, c, n);
148
+ if (!c.fixed) dragger(button, th, c);
149
+ return th;
150
+ }
151
+
152
+ // Drag the edge: the column follows the pointer, kept to its minimum;
153
+ // the width saves on release. Arrow keys step it; a double-click resets.
154
+ function resizer(grip, c, n) {
155
+ const col = cols[n];
156
+ const set = (px) => {
157
+ const w = Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(c.kind), px)));
158
+ col.style.width = `${w}px`;
159
+ grip.setAttribute('aria-valuenow', String(w));
160
+ return w;
161
+ };
162
+ const save = (w) => config.change({ widths: { ...widths(), [c.id]: w } });
163
+ grip.addEventListener('pointerdown', (event) => {
164
+ if (event.button !== 0) return;
165
+ event.preventDefault();
166
+ event.stopPropagation();
167
+ grip.setPointerCapture(event.pointerId);
168
+ const from = event.clientX;
169
+ const start = col.getBoundingClientRect().width || widthOf(c, widths());
170
+ const base = node.getBoundingClientRect().width;
171
+ let w = start;
172
+ node.dataset.resizing = '';
173
+ const move = (e) => {
174
+ w = set(start + e.clientX - from);
175
+ node.style.width = `${base + w - start}px`;
176
+ };
177
+ const up = () => {
178
+ grip.removeEventListener('pointermove', move);
179
+ delete node.dataset.resizing;
180
+ if (Math.round(w) !== Math.round(start)) save(w);
181
+ };
182
+ grip.addEventListener('pointermove', move);
183
+ grip.addEventListener('pointerup', up, { once: true });
184
+ grip.addEventListener('pointercancel', up, { once: true });
185
+ });
186
+ grip.addEventListener('click', (event) => event.stopPropagation());
187
+ grip.addEventListener('dblclick', (event) => {
188
+ event.stopPropagation();
189
+ config.change({ widths: withoutKey(widths(), c.id) });
190
+ });
191
+ grip.addEventListener('keydown', (event) => {
192
+ const step = event.key === 'ArrowRight' ? 16 : event.key === 'ArrowLeft' ? -16 : 0;
193
+ if (!step) return;
194
+ event.preventDefault();
195
+ save(set(widthOf(c, widths()) + step));
196
+ });
197
+ }
198
+ }
199
+
200
+ // Drag a head: past a few pixels it lifts, a line shows where it lands,
201
+ // and the release moves it there. A click without a drag opens the menu.
202
+ let dragged = false;
203
+ function dragger(button, th, c) {
204
+ button.addEventListener('pointerdown', (event) => {
205
+ if (event.button !== 0) return;
206
+ const row = th.parentElement;
207
+ const from = event.clientX;
208
+ let target = null;
209
+ dragged = false;
210
+ const heads = () => [...row.querySelectorAll('th.fx-th--drag')];
211
+ const clear = () => { for (const other of row.children) other.removeAttribute('data-drop'); };
212
+ const move = (e) => {
213
+ if (!dragged && Math.abs(e.clientX - from) < 6) return;
214
+ if (!dragged) {
215
+ dragged = true;
216
+ button.setPointerCapture(event.pointerId);
217
+ th.dataset.dragging = '';
218
+ }
219
+ clear();
220
+ target = null;
221
+ for (const other of heads()) {
222
+ const box = other.getBoundingClientRect();
223
+ if (e.clientX >= box.left && e.clientX < box.right && other !== th) {
224
+ const side = e.clientX < box.left + box.width / 2 ? 'before' : 'after';
225
+ other.dataset.drop = side;
226
+ target = { id: other.dataset.col, side };
227
+ }
228
+ }
229
+ };
230
+ const up = () => {
231
+ removeEventListener('pointermove', move);
232
+ delete th.dataset.dragging;
233
+ clear();
234
+ if (dragged && target) {
235
+ const order = ids().filter((id) => id !== c.id);
236
+ const at = order.indexOf(target.id) + (target.side === 'after' ? 1 : 0);
237
+ config.change({ columns: moveColumn(ids(), c.id, at) });
238
+ }
239
+ // The click that follows a drag does not open the menu.
240
+ setTimeout(() => { dragged = false; });
241
+ };
242
+ addEventListener('pointermove', move);
243
+ addEventListener('pointerup', up, { once: true });
244
+ addEventListener('pointercancel', up, { once: true });
245
+ });
246
+ }
247
+
248
+ return { table, menu, openMenu };
249
+ }
250
+
251
+ function withoutKey(object, key) {
252
+ const { [key]: _gone, ...rest } = object;
253
+ return rest;
254
+ }
255
+
256
+ // Saves a page's view with the person's account (PUT /api/me/views/:page)
257
+ // shortly after a change, so a few quick changes make one request.
258
+ export function viewSaver(ui, page) {
259
+ let timer = null;
260
+ return (view) => {
261
+ if (ui.me) ui.me.views = { ...ui.me.views, [page]: view };
262
+ clearTimeout(timer);
263
+ timer = setTimeout(() => {
264
+ ui.flow(`/api/me/views/${page}`, { method: 'PUT', body: JSON.stringify(view) })
265
+ .catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
266
+ }, 500);
267
+ };
268
+ }