@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.
@@ -9,19 +9,22 @@
9
9
  // option(text, checked, onclick, role, icon) a row in a panel, with a tick.
10
10
  // title, choose, bin, addButton, rowsFoot, clearAll the parts of a rows
11
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 }]
12
+ // filterMenu(config) the Filter chip, as ClickUp's: groups of rows of
13
+ // field, operator and value, And / Or in a group and between groups,
14
+ // "Add nested filter", "Add filter" and "Clear all" (job-view.js has
15
+ // the rules and filterJobs, which keeps the rows that pass). config:
16
+ // fields() [{ value, label, icon? } | { group, options }]
16
17
  // kindOf(field) 'choice' | 'person' | 'date' | 'number' | 'check' | 'text' | null
17
18
  // opsOf(field) { op: words }, or null for OPERATORS[kindOf(field)]
18
19
  // choicesOf(field) [{ value, label, icon? }] for a choice field
19
- // view() { filters, match }
20
- // change(patch) saves { filters } or { match }
20
+ // view() { match, groups: [{ match, filters }] }
21
+ // change(patch) saves { groups } or { match }
21
22
  // first the field a new row starts on
22
23
  // empty the words when no filter is on
23
24
  import { lucide } from '/assets/ui/custom-fields.js';
24
- import { filterCount, OPERATORS } from '/assets/ui/job-view.js';
25
+ import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
26
+
27
+ const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
25
28
 
26
29
  export const ICONS = {
27
30
  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>',
@@ -88,6 +91,17 @@ export function viewBar(h) {
88
91
  type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
89
92
  }, h('span', { class: 'fx-pop__tick' }, checked ? svg(ICONS.tick) : null), icon ? lucide(h, icon) : null, h('span', {}, text));
90
93
 
94
+ // A choice in a list where many can be ticked, as ClickUp's value list:
95
+ // the choice's mark at the left only when it has one (a Lucide `icon`, a
96
+ // custom field `color`, or a status `rank`), its name, a tick box at the
97
+ // right.
98
+ const mark = (c) => (c.icon ? lucide(h, c.icon)
99
+ : c.color ? h('span', { class: 'fx-dot', 'data-color': c.color, 'aria-hidden': 'true' })
100
+ : c.rank != null ? h('span', { class: 'fx-dot fx-dot--stage', 'data-rank': String(c.rank), 'aria-hidden': 'true' }) : null);
101
+ const checkOption = (c, checked, onclick) => h('button', {
102
+ type: 'button', class: 'fx-pop__item fx-pop__item--check', role: 'menuitemcheckbox', 'aria-checked': String(checked), onclick,
103
+ }, mark(c), h('span', { class: 'fx-pop__text' }, c.label), h('span', { class: 'fx-pop__box', 'aria-hidden': 'true' }, checked ? svg(ICONS.tick) : null));
104
+
91
105
  // The parts of a rows panel (Group by, Sort, Filters): a title, rows of
92
106
  // selects with a bin at the end, and a foot with "Add" and "Clear all".
93
107
  const title = (text) => h('p', { class: 'fx-rows__title' }, text);
@@ -101,113 +115,205 @@ export function viewBar(h) {
101
115
  const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
102
116
  const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
103
117
 
118
+ // A button that opens a list to pick one choice from, as ClickUp's menus
119
+ // in a filter row: options [{ value, label, icon? }], a search box when
120
+ // `search` is on, and a tick on the chosen one.
121
+ function picker({ label, value, options, onpick, search = false, empty = 'Choose…', extra = '' }) {
122
+ const id = `fx-view-pick-${++choiceCount}`;
123
+ const chosen = options.find((o) => o.value === value) ?? null;
124
+ const button = h('button', {
125
+ type: 'button', class: `fx-select fx-select--sm fx-choice ${extra}`.trim(), popovertarget: id, 'aria-label': label, title: chosen?.label ?? label,
126
+ }, chosen?.icon ? lucide(h, chosen.icon) : null, h('span', { class: 'fx-choice__text' }, chosen?.label ?? empty));
127
+ const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
128
+ const find = search ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Search…', 'aria-label': `Search ${label.toLowerCase()}` }) : null;
129
+ const items = h('div', {});
130
+ const drawItems = () => {
131
+ const q = find?.value.trim().toLowerCase() ?? '';
132
+ const shown = options.filter((o) => !q || o.label.toLowerCase().includes(q));
133
+ items.replaceChildren(...(shown.length ? shown.map((o) => option(o.label, o.value === value, () => { pop.hidePopover(); onpick(o.value); }, 'menuitemradio', o.icon ?? null))
134
+ : [h('p', { class: 'fx-rows__empty' }, 'Nothing matches.')]));
135
+ };
136
+ find?.addEventListener('input', drawItems);
137
+ pop.append(...[find, items].filter(Boolean));
138
+ pop.addEventListener('toggle', (event) => {
139
+ if (event.newState !== 'open') return;
140
+ if (find) find.value = '';
141
+ drawItems();
142
+ place(button, pop);
143
+ find?.focus();
144
+ });
145
+ return [button, pop];
146
+ }
147
+ const place = (button, pop) => {
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
+ };
152
+
104
153
  // ── 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.
154
+ // As ClickUp's: groups of rows of field, operator and value, with a bin on
155
+ // each row. A group's second row has And / Or for the rows in it; the
156
+ // second group has And / Or between the groups. "Add nested filter" adds a
157
+ // row to a group, "Add filter" a new group, "Clear all" empties it.
107
158
  function filterMenu({ fields, kindOf, opsOf: ownOps = () => null, choicesOf = () => [], view, change, first, empty = 'No filters. Every row shows.' }) {
108
159
  const opsOf = (field) => ownOps(field) ?? OPERATORS[kindOf(field)] ?? {};
109
- // A row's value when its operator changes: kept when it still fits.
160
+ // The field list: one list, as ClickUp's, each field with its type icon.
161
+ const fieldList = () => fields().flatMap((o) => (o.group ? o.options : [o]))
162
+ .map((o) => ({ ...o, icon: o.icon ?? KIND_ICONS[kindOf(o.value)] ?? null }));
163
+ // A row's value when its field or operator changes: kept when it fits.
110
164
  function valueFor(field, op, old) {
111
165
  const kind = kindOf(field);
166
+ if (kind === 'date' && ['is', 'not'].includes(op)) return typeof old === 'string' && dateValueIsOn(old) ? old : 'today';
112
167
  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
168
  if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
115
- if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
169
+ if (['has', 'hasnt', 'is'].includes(op)) return typeof old === 'string' ? old : '';
116
170
  return null;
117
171
  }
118
- const setFilters = (filters) => change({ filters });
119
- const setRow = (i, patch) => setFilters(view().filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
172
+ const groups = () => view().groups;
173
+ const setGroups = (next) => change({ groups: next.filter((g) => g.filters.length) });
174
+ const newRow = (field = first) => {
175
+ const op = Object.keys(opsOf(field))[0];
176
+ return { field, op, value: valueFor(field, op, null) };
177
+ };
178
+ const setRow = (gi, ri, patch) => setGroups(groups().map((g, n) => (n === gi ? { ...g, filters: g.filters.map((f, m) => (m === ri ? { ...f, ...patch } : f)) } : g)));
179
+ const dropRow = (gi, ri) => setGroups(groups().map((g, n) => (n === gi ? { ...g, filters: g.filters.filter((_, m) => m !== ri) } : g)));
120
180
  // 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) {
181
+ // with a search and "Select all", as ClickUp's.
182
+ function choiceValue(gi, ri, filter) {
123
183
  const choices = choicesOf(filter.field);
124
184
  const named = () => {
125
185
  const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
126
- return on.length ? on.join(', ') : 'Choose…';
186
+ return on.length ? on.join(', ') : 'Select option';
127
187
  };
128
188
  const id = `fx-view-choice-${++choiceCount}`;
129
189
  const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
130
190
  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;
191
+ const find = h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Search…', 'aria-label': 'Search the choices' });
132
192
  const items = h('div', {});
193
+ const save = (value) => {
194
+ filter = { ...filter, value };
195
+ setRow(gi, ri, { value });
196
+ button.textContent = named();
197
+ button.title = named();
198
+ drawItems();
199
+ };
200
+ const all = h('button', { type: 'button', class: 'fx-pop__link', onclick: () => save(filter.value.length === choices.length ? [] : choices.map((c) => c.value)) });
133
201
  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)));
202
+ const q = find.value.trim().toLowerCase();
203
+ all.textContent = filter.value.length === choices.length && choices.length ? 'Clear' : 'Select all';
204
+ items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => checkOption(c, filter.value.includes(c.value), () => {
205
+ save(filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value]);
206
+ })));
142
207
  };
143
- find?.addEventListener('input', drawItems);
144
- pop.append(...[find, items].filter(Boolean));
208
+ find.addEventListener('input', drawItems);
209
+ pop.append(find, h('div', { class: 'fx-pop__bar' }, h('span', { class: 'fx-pop__head' }, fieldList().find((f) => f.value === filter.field)?.label ?? 'Options'), all), items);
145
210
  pop.addEventListener('toggle', (event) => {
146
211
  if (event.newState !== 'open') return;
147
212
  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();
213
+ place(button, pop);
214
+ find.focus();
152
215
  });
153
216
  return [button, pop];
154
217
  }
155
- function valueControl(i, filter) {
218
+ // A date row's value: ClickUp's list of ranges, then a day, two days or
219
+ // a count of days when the range needs one.
220
+ function dateValue(gi, ri, filter) {
221
+ const value = filter.value ?? 'today';
222
+ const [kind, rest = ''] = value.split(':');
223
+ const range = DAY_BETWEEN.exec(value);
224
+ const base = range ? 'between' : kind;
225
+ const set = (next) => { setRow(gi, ri, { value: next }); };
226
+ const pick = picker({
227
+ label: 'Date', value: base, search: true, extra: 'fx-rows__value',
228
+ options: Object.entries(DATE_VALUES).map(([v, l]) => ({ value: v, label: l })),
229
+ onpick: (v) => {
230
+ const day = today();
231
+ set({ on: `on:${day}`, before: `before:${day}`, after: `after:${day}`, between: `between:${day}..${day}`, last: 'last:7', next: 'next:7' }[v] ?? v);
232
+ redrawPanel();
233
+ },
234
+ });
235
+ const dayInput = (at, onchange) => h('input', { class: 'fx-input fx-input--sm', type: 'date', 'aria-label': 'Day', value: at, onchange: (event) => { if (event.target.value) onchange(event.target.value); } });
236
+ let more = null;
237
+ if (['on', 'before', 'after'].includes(base)) more = dayInput(rest, (d) => set(`${base}:${d}`));
238
+ if (range) {
239
+ more = h('span', { class: 'fx-rows__days' },
240
+ dayInput(range[1], (d) => set(`between:${d}..${range[2]}`)), dayInput(range[2], (d) => set(`between:${range[1]}..${d}`)));
241
+ }
242
+ if (['last', 'next'].includes(base)) {
243
+ more = h('span', { class: 'fx-rows__days' }, h('input', {
244
+ class: 'fx-input fx-input--sm', type: 'number', min: '1', max: '9999', step: '1', 'aria-label': 'Days', value: rest,
245
+ oninput: (event) => { const n = Number(event.target.value); if (Number.isInteger(n) && n > 0 && n < 10000) set(`${base}:${n}`); },
246
+ }), h('span', { class: 'fx-rows__lead' }, 'days'));
247
+ }
248
+ return h('span', { class: 'fx-rows__value fx-rows__date' }, ...pick, more);
249
+ }
250
+ function valueControl(gi, ri, filter) {
156
251
  const kind = kindOf(filter.field);
157
252
  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
- }
253
+ if (kind === 'date' && ['is', 'not'].includes(op)) return dateValue(gi, ri, filter);
254
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(gi, ri, filter);
162
255
  if (['eq', 'gt', 'lt'].includes(op)) {
163
256
  return h('input', {
164
257
  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) }),
258
+ oninput: (event) => setRow(gi, ri, { value: event.target.value === '' ? null : Number(event.target.value) }),
166
259
  });
167
260
  }
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 }) });
261
+ if (['has', 'hasnt', 'is'].includes(op)) {
262
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', placeholder: 'Search…', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(gi, ri, { value: event.target.value }) });
170
263
  }
171
264
  return h('span', { class: 'fx-rows__value fx-rows__value--none' });
172
265
  }
266
+ // And / Or: a small menu on the second row (or group), the word after it.
267
+ const joinControl = (label, match, onpick) => picker({
268
+ label, value: match, extra: 'fx-rows__join', options: [{ value: 'all', label: 'AND' }, { value: 'any', label: 'OR' }], onpick,
269
+ });
270
+ let redrawPanel = () => {};
173
271
  const control = menu(ICONS.filter, (redraw) => {
174
- const { filters, match } = view();
175
- const list = fields();
272
+ redrawPanel = redraw;
273
+ const list = fieldList();
274
+ const all = groups();
275
+ const { match } = view();
276
+ const row = (gi, g, filter, ri) => {
277
+ const lead = ri === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
278
+ : ri === 1 ? joinControl('Match in this group', g.match, (value) => { setGroups(all.map((x, n) => (n === gi ? { ...x, match: value } : x))); redraw(); })
279
+ : h('span', { class: 'fx-rows__lead fx-rows__word' }, g.match === 'any' ? 'OR' : 'AND');
280
+ const remove = bin('Remove this filter', () => { dropRow(gi, ri); redraw(); });
281
+ if (!kindOf(filter.field)) return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), remove);
282
+ const ops = opsOf(filter.field);
283
+ return h('div', { class: 'fx-rows__row' },
284
+ lead,
285
+ ...picker({
286
+ label: 'Field', value: filter.field, options: list, search: true,
287
+ onpick: (field) => { const op = Object.keys(opsOf(field))[0]; setRow(gi, ri, { field, op, value: valueFor(field, op, null) }); redraw(); },
288
+ }),
289
+ ...picker({
290
+ label: 'Operator', value: filter.op, options: Object.entries(ops).map(([value, label]) => ({ value, label })),
291
+ onpick: (op) => { setRow(gi, ri, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); },
292
+ }),
293
+ valueControl(gi, ri, filter),
294
+ remove);
295
+ };
296
+ const count = all.reduce((n, g) => n + g.filters.length, 0);
297
+ const box = (g, gi) => h('div', { class: 'fx-fgroup' },
298
+ h('div', { class: 'fx-rows' }, g.filters.map((filter, ri) => row(gi, g, filter, ri))),
299
+ count < 20 ? h('button', { type: 'button', class: 'fx-fgroup__add', onclick: () => { setGroups(all.map((x, n) => (n === gi ? { ...x, filters: [...x.filters, newRow()] } : x))); redraw(); } }, 'Add nested filter') : null);
300
+ const body = all.length === 1 ? box(all[0], 0) : h('div', { class: 'fx-fgroups' }, all.map((g, gi) => h('div', { class: 'fx-fgroups__row' },
301
+ gi === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
302
+ : gi === 1 ? h('span', {}, ...joinControl('Match between groups', match, (value) => { change({ match: value }); redraw(); }))
303
+ : h('span', { class: 'fx-rows__lead fx-rows__word' }, match === 'any' ? 'OR' : 'AND'),
304
+ box(g, gi))));
176
305
  return [
177
306
  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),
307
+ all.length ? body : h('p', { class: 'fx-rows__empty' }, empty),
198
308
  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),
309
+ count < 20 ? addButton('Add filter', () => { setGroups([...all, { match: 'all', filters: [newRow()] }]); redraw(); }) : h('span'),
310
+ all.length ? clearAll(() => { change({ groups: [], match: 'all' }); redraw(); }) : null),
205
311
  ];
206
312
  }, { rows: true, clearLabel: 'Clear all filters' });
207
313
  // The chip names how many filters are on.
208
314
  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);
315
+ const count = filterCount(view(), kindOf);
316
+ control.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, groups().length ? () => change({ groups: [], match: 'all' }) : null);
211
317
  };
212
318
  return { ...control, draw };
213
319
  }