@arcforgelabs/flow 2026.10.41 → 2026.10.43

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,99 @@
1
+ // Tips: every control that shows only an icon says its name on hover and on
2
+ // keyboard focus, in one small tip beside it. One listener on the document
3
+ // serves the whole app, so a new icon button needs only a name:
4
+ //
5
+ // data-tip="Clear" the tip, when it differs from the spoken name
6
+ // aria-label="Close" an icon-only control's name is its tip
7
+ // title="Search" taken over as the tip (no second, native tip)
8
+ //
9
+ // A control with words on it shows no tip. Clerk's own components keep their
10
+ // own tips. The tip floats on <body>, so a scrolling list cannot cut it off.
11
+
12
+ const INTERACTIVE = 'button, a[href], [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"], summary';
13
+ const CANDIDATE = `[data-tip], ${INTERACTIVE.split(', ').map((s) => `${s}[aria-label], ${s}[title]`).join(', ')}, [role="img"][aria-label], [role="img"][title]`;
14
+
15
+ // True when the element shows no word of its own: only icons or a symbol.
16
+ export function iconOnly(text) {
17
+ return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
18
+ }
19
+
20
+ // The tip for this element, or null. `el` needs getAttribute, textContent
21
+ // and closest (a DOM element, or a test's stand-in).
22
+ export function tipText(el) {
23
+ if (!el || el.closest?.('[class*="cl-"]')) return null;
24
+ if (!iconOnly(el.textContent) && !el.getAttribute('data-tip')) return null;
25
+ return el.getAttribute('title') || el.getAttribute('data-tip') || el.getAttribute('aria-label') || null;
26
+ }
27
+
28
+ // Marks a control icon-only: its spoken name and its tip. Returns the node.
29
+ export function named(node, label, tip = label) {
30
+ node.setAttribute('aria-label', label);
31
+ node.setAttribute('data-tip', tip);
32
+ node.removeAttribute('title');
33
+ return node;
34
+ }
35
+
36
+ let bubble = null;
37
+ let anchor = null;
38
+
39
+ function place() {
40
+ if (!bubble || !anchor) return;
41
+ const box = anchor.getBoundingClientRect();
42
+ const tip = bubble.getBoundingClientRect();
43
+ const gap = 6;
44
+ // A folded sidebar's icons take their tip at the right, as in ClickUp.
45
+ if (anchor.dataset.tipSide === 'right') {
46
+ bubble.style.transform = `translate(${Math.round(box.right + gap)}px, ${Math.round(box.top + box.height / 2 - tip.height / 2)}px)`;
47
+ return;
48
+ }
49
+ const above = box.top - tip.height - gap;
50
+ const top = above >= 4 ? above : box.bottom + gap;
51
+ const left = Math.min(Math.max(4, box.left + box.width / 2 - tip.width / 2), window.innerWidth - tip.width - 4);
52
+ bubble.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
53
+ }
54
+
55
+ function show(el) {
56
+ const text = tipText(el);
57
+ if (!text) return;
58
+ // A native title would show a second tip after a pause, so the tip takes
59
+ // it over (a title set again later wins).
60
+ if (el.hasAttribute('title')) {
61
+ if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.getAttribute('title'));
62
+ el.setAttribute('data-tip', el.getAttribute('title'));
63
+ el.removeAttribute('title');
64
+ return show(el);
65
+ }
66
+ if (!bubble) {
67
+ bubble = document.createElement('div');
68
+ bubble.className = 'fx-tip';
69
+ bubble.setAttribute('role', 'tooltip');
70
+ bubble.setAttribute('aria-hidden', 'true');
71
+ }
72
+ bubble.textContent = text;
73
+ anchor = el;
74
+ (el.closest('dialog[open]') ?? document.body).append(bubble);
75
+ place();
76
+ }
77
+
78
+ function hide(el) {
79
+ if (el && el !== anchor) return;
80
+ anchor = null;
81
+ bubble?.remove();
82
+ }
83
+
84
+ export function startTips() {
85
+ document.addEventListener('pointerover', (event) => {
86
+ if (event.pointerType === 'touch') return;
87
+ const el = event.target.closest?.(CANDIDATE);
88
+ if (el && el !== anchor) show(el);
89
+ else if (!el) hide();
90
+ });
91
+ document.addEventListener('focusin', (event) => {
92
+ const el = event.target.closest?.(CANDIDATE);
93
+ if (el && el.matches(':focus-visible')) show(el);
94
+ });
95
+ document.addEventListener('focusout', (event) => hide(event.target.closest?.(CANDIDATE)));
96
+ document.addEventListener('pointerdown', () => hide());
97
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hide(); });
98
+ window.addEventListener('scroll', () => hide(), true);
99
+ }
@@ -49,7 +49,7 @@ export async function renderXeroImport(main, ui) {
49
49
  const history = params.get('show') === 'all';
50
50
  const icon = (name, label) => lucide(h, name, label);
51
51
  const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
52
- const subtitle = h('span', {});
52
+ const tally = h('span', {});
53
53
  const actions = [];
54
54
  let view = params.get('view') === 'list' ? 'list' : 'card';
55
55
  if (!history) {
@@ -66,11 +66,8 @@ export async function renderXeroImport(main, ui) {
66
66
  renderReview();
67
67
  }
68
68
  main.append(
69
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
70
- h('a', { href: '/integrations', onclick: (event) => ui.go(event) }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'),
71
- h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
72
- h('span', {}, history ? 'History' : 'Review')),
73
- ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
69
+ ui.crumbs(['/integrations', 'Integrations'], ['/integrations/xero', 'Xero'], history ? 'History' : 'Review'),
70
+ ui.pageHeader(history ? 'Xero history' : 'Review', { count: tally, actions }),
74
71
  host);
75
72
 
76
73
  let organisationId;
@@ -117,7 +114,7 @@ export async function renderXeroImport(main, ui) {
117
114
  return;
118
115
  }
119
116
  if (!rows.length) {
120
- subtitle.replaceChildren('');
117
+ tally.replaceChildren('');
121
118
  host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
122
119
  return;
123
120
  }
@@ -134,7 +131,7 @@ export async function renderXeroImport(main, ui) {
134
131
  const fit = flowFit(row, pick);
135
132
  const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
136
133
  h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
137
- subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
134
+ tally.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
138
135
  const days = daysApart(row.issuedOn, fit.date);
139
136
  const facts = [
140
137
  ['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
@@ -195,7 +192,7 @@ export async function renderXeroImport(main, ui) {
195
192
 
196
193
  // Every one to check, best fit beside it; ticked rows go from the bulk bar.
197
194
  function reviewList(rows) {
198
- subtitle.replaceChildren(`${rows.length} to check`);
195
+ tally.replaceChildren(`${rows.length} to check`);
199
196
  const ticked = new Set();
200
197
  const boxes = new Map();
201
198
  const drawBar = () => {
@@ -315,7 +312,7 @@ export async function renderXeroImport(main, ui) {
315
312
  return;
316
313
  }
317
314
  const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
318
- subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
315
+ tally.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
319
316
  shown = offset === 0 ? page.rows : [...shown, ...page.rows];
320
317
  const rows = page.rows.map(rowOf);
321
318
  if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));