@arcforgelabs/flow 2026.10.40 → 2026.10.42

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,236 @@
1
+ // The view bar over a list: chips that open a small panel (Group, Sort,
2
+ // Filter, Columns), as on ClickUp. Every list page builds its chips here, so
3
+ // they look and work the same: Jobs (jobs.js) and Reconcile (reconcile.js).
4
+ //
5
+ // viewBar(h) answers the parts:
6
+ // menu(icon, build, { rows, clearLabel }) a chip, its × and its panel.
7
+ // build(redraw, close) answers the panel's nodes. set(label, on, clear)
8
+ // names the chip, marks it on, and gives its × an action.
9
+ // option(text, checked, onclick, role, icon) a row in a panel, with a tick.
10
+ // title, choose, bin, addButton, rowsFoot, clearAll the parts of a rows
11
+ // panel: a title, rows of selects with a bin, a foot with Add and Clear.
12
+ // filterMenu(config) the Filter chip: rows of field, operator and value,
13
+ // And / Or, "Add filter" and "Clear all" (job-view.js has the rules and
14
+ // filterJobs, which keeps the rows that pass). config:
15
+ // fields() [{ value, label } | { group, options }]
16
+ // kindOf(field) 'choice' | 'person' | 'date' | 'number' | 'check' | 'text' | null
17
+ // opsOf(field) { op: words }, or null for OPERATORS[kindOf(field)]
18
+ // choicesOf(field) [{ value, label, icon? }] for a choice field
19
+ // view() { filters, match }
20
+ // change(patch) saves { filters } or { match }
21
+ // first the field a new row starts on
22
+ // empty the words when no filter is on
23
+ import { lucide } from '/assets/ui/custom-fields.js';
24
+ import { filterCount, OPERATORS } from '/assets/ui/job-view.js';
25
+
26
+ export const ICONS = {
27
+ group: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 14.5 5 8 8.2 1.5 5z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="m1.5 8.2 6.5 3.2 6.5-3.2M1.5 11.2 8 14.4l6.5-3.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
28
+ sort: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5-3.5L11.5 6M4.5 10 8 13.5l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
29
+ columns: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M6 2.5v11M10 2.5v11" stroke="currentColor" stroke-width="1.3"/></svg>',
30
+ filter: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12M4.5 8h7M7 12.5h2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
31
+ tick: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 8.5 3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
32
+ };
33
+
34
+ export function svg(markup) {
35
+ const span = document.createElement('span');
36
+ span.innerHTML = markup;
37
+ return span.firstChild;
38
+ }
39
+
40
+ // The local calendar day, as yyyy-mm-dd.
41
+ export const today = () => {
42
+ const now = new Date();
43
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
44
+ };
45
+
46
+ // Popover ids are unique on the page, whichever page builds the bar.
47
+ let menuCount = 0;
48
+ let choiceCount = 0;
49
+
50
+ export function viewBar(h) {
51
+ // One toolbar control: a chip that opens a small panel under it, and an ×
52
+ // that clears what the chip sets (as ClickUp's "Clear all filters"). The
53
+ // panel is a popover, so a click outside or Escape closes it.
54
+ function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
55
+ const id = `fx-view-menu-${++menuCount}`;
56
+ const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
57
+ const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
58
+ const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
59
+ const wrap = h('span', { class: 'fx-chipset' }, button, clear);
60
+ let onClear = null;
61
+ clear.addEventListener('click', () => onClear?.());
62
+ const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
63
+ panel.addEventListener('toggle', (event) => {
64
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
65
+ if (event.newState !== 'open') return;
66
+ fill();
67
+ const at = button.getBoundingClientRect();
68
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
69
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
70
+ });
71
+ return {
72
+ node: [wrap, panel],
73
+ wrap,
74
+ fill: () => { if (panel.matches(':popover-open')) fill(); },
75
+ set(label, on, clearIt = null) {
76
+ button.replaceChildren(svg(icon), h('span', {}, label));
77
+ wrap.classList.toggle('fx-chipset--on', on);
78
+ button.classList.toggle('fx-chip--on', on);
79
+ onClear = clearIt;
80
+ clear.hidden = !clearIt;
81
+ },
82
+ };
83
+ }
84
+
85
+ // A choice in a menu: a row with a tick when it is chosen, and an icon
86
+ // (a Lucide name) when the choice has one.
87
+ const option = (text, checked, onclick, role = 'menuitemradio', icon = null) => h('button', {
88
+ type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
89
+ }, h('span', { class: 'fx-pop__tick' }, checked ? svg(ICONS.tick) : null), icon ? lucide(h, icon) : null, h('span', {}, text));
90
+
91
+ // The parts of a rows panel (Group by, Sort, Filters): a title, rows of
92
+ // selects with a bin at the end, and a foot with "Add" and "Clear all".
93
+ const title = (text) => h('p', { class: 'fx-rows__title' }, text);
94
+ const choose = (label, value, options, onchange, extra = {}) => h('select', {
95
+ class: 'fx-select fx-select--sm', 'aria-label': label, onchange: (event) => onchange(event.target.value), ...extra,
96
+ }, options.map((o) => (o.group
97
+ ? (o.options.length ? h('optgroup', { label: o.group }, o.options.map((x) => h('option', { value: x.value, selected: x.value === value }, x.label))) : null)
98
+ : h('option', { value: o.value, selected: o.value === value, disabled: o.disabled }, o.label))));
99
+ const bin = (label, onclick) => h('button', { type: 'button', class: 'fx-rows__bin', 'aria-label': label, title: label, onclick }, lucide(h, 'trash-2'));
100
+ const addButton = (text, onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rows__add', onclick }, lucide(h, 'plus'), text);
101
+ const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
102
+ const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
103
+
104
+ // ── Filters ──
105
+ // Rows of field, operator and value, with a bin on each, "Add filter",
106
+ // and "Clear all". The second row's And / Or applies to every row.
107
+ function filterMenu({ fields, kindOf, opsOf: ownOps = () => null, choicesOf = () => [], view, change, first, empty = 'No filters. Every row shows.' }) {
108
+ const opsOf = (field) => ownOps(field) ?? OPERATORS[kindOf(field)] ?? {};
109
+ // A row's value when its operator changes: kept when it still fits.
110
+ function valueFor(field, op, old) {
111
+ const kind = kindOf(field);
112
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
113
+ if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
114
+ if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
115
+ if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
116
+ return null;
117
+ }
118
+ const setFilters = (filters) => change({ filters });
119
+ const setRow = (i, patch) => setFilters(view().filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
120
+ // A choice row's value: a button that opens a list of choices to tick,
121
+ // with a search when the list is long.
122
+ function choiceValue(i, filter) {
123
+ const choices = choicesOf(filter.field);
124
+ const named = () => {
125
+ const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
126
+ return on.length ? on.join(', ') : 'Choose…';
127
+ };
128
+ const id = `fx-view-choice-${++choiceCount}`;
129
+ const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
130
+ const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
131
+ const find = choices.length > 8 ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Find', 'aria-label': 'Find a choice' }) : null;
132
+ const items = h('div', {});
133
+ const drawItems = () => {
134
+ const q = find?.value.trim().toLowerCase() ?? '';
135
+ items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => option(c.label, filter.value.includes(c.value), () => {
136
+ filter = { ...filter, value: filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value] };
137
+ setRow(i, { value: filter.value });
138
+ button.textContent = named();
139
+ button.title = named();
140
+ drawItems();
141
+ }, 'menuitemcheckbox', c.icon ?? null)));
142
+ };
143
+ find?.addEventListener('input', drawItems);
144
+ pop.append(...[find, items].filter(Boolean));
145
+ pop.addEventListener('toggle', (event) => {
146
+ if (event.newState !== 'open') return;
147
+ drawItems();
148
+ const at = button.getBoundingClientRect();
149
+ pop.style.top = `${Math.round(at.bottom + 4)}px`;
150
+ pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
151
+ find?.focus();
152
+ });
153
+ return [button, pop];
154
+ }
155
+ function valueControl(i, filter) {
156
+ const kind = kindOf(filter.field);
157
+ const { op } = filter;
158
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(i, filter);
159
+ if (['before', 'after', 'on'].includes(op)) {
160
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
161
+ }
162
+ if (['eq', 'gt', 'lt'].includes(op)) {
163
+ return h('input', {
164
+ class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
165
+ oninput: (event) => setRow(i, { value: event.target.value === '' ? null : Number(event.target.value) }),
166
+ });
167
+ }
168
+ if (['has', 'is'].includes(op)) {
169
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(i, { value: event.target.value }) });
170
+ }
171
+ return h('span', { class: 'fx-rows__value fx-rows__value--none' });
172
+ }
173
+ const control = menu(ICONS.filter, (redraw) => {
174
+ const { filters, match } = view();
175
+ const list = fields();
176
+ return [
177
+ title('Filters'),
178
+ filters.length ? h('div', { class: 'fx-rows' }, filters.map((filter, i) => {
179
+ const known = kindOf(filter.field);
180
+ const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
181
+ : i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
182
+ : h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
183
+ if (!known) {
184
+ return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
185
+ }
186
+ const ops = opsOf(filter.field);
187
+ return h('div', { class: 'fx-rows__row' },
188
+ lead,
189
+ choose('Field', filter.field, list, (field) => {
190
+ const op = Object.keys(opsOf(field))[0];
191
+ setRow(i, { field, op, value: valueFor(field, op, null) });
192
+ redraw();
193
+ }),
194
+ choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
195
+ valueControl(i, filter),
196
+ bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
197
+ })) : h('p', { class: 'fx-rows__empty' }, empty),
198
+ rowsFoot(
199
+ filters.length < 20 ? addButton('Add filter', () => {
200
+ const op = Object.keys(opsOf(first))[0];
201
+ setFilters([...filters, { field: first, op, value: valueFor(first, op, null) }]);
202
+ redraw();
203
+ }) : h('span'),
204
+ filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
205
+ ];
206
+ }, { rows: true, clearLabel: 'Clear all filters' });
207
+ // The chip names how many filters are on.
208
+ const draw = () => {
209
+ const count = filterCount(view());
210
+ control.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, view().filters.length ? () => change({ filters: [], match: 'all' }) : null);
211
+ };
212
+ return { ...control, draw };
213
+ }
214
+
215
+ return { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu };
216
+ }
217
+
218
+ // A list's own search: a round search icon in the view bar that opens into a
219
+ // small box while it has words, so it takes no room until it is used. The
220
+ // global search (sidebar foot, Ctrl+K) finds anything anywhere; this one
221
+ // narrows the rows on the page. Escape clears it.
222
+ export function findBox(h, { label, value = '', oninput }) {
223
+ const input = h('input', { class: 'fx-input fx-input--sm fx-find__input', type: 'search', placeholder: label, 'aria-label': label, value });
224
+ const button = h('button', { type: 'button', class: 'fx-chip fx-find__button', 'aria-label': label, title: label }, lucide(h, 'search'));
225
+ const element = h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input);
226
+ button.addEventListener('click', () => { element.classList.add('fx-find--open'); input.focus(); });
227
+ input.addEventListener('input', () => oninput(input.value));
228
+ input.addEventListener('blur', () => { if (!input.value) element.classList.remove('fx-find--open'); });
229
+ input.addEventListener('keydown', (event) => {
230
+ if (event.key !== 'Escape') return;
231
+ input.value = '';
232
+ oninput('');
233
+ input.blur();
234
+ });
235
+ return { element, input };
236
+ }