@arcforgelabs/flow 2026.10.35 → 2026.10.37
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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +81 -50
- package/package.json +6 -5
- package/server/api/invoicing.js +23 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +158 -11
- package/server/ui/invoicing.js +291 -45
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +11 -1
|
@@ -75,10 +75,10 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
75
75
|
if (!given) runtime = createXeroRuntime({ settings: requireXeroSettings(env ?? process.env) });
|
|
76
76
|
const { settings } = current();
|
|
77
77
|
if (reconfigured) await current().guardFor(db).resume();
|
|
78
|
-
log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
|
|
78
|
+
log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : settings.backgroundJobs === false ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
|
|
79
79
|
// The history import (import.js) runs in the background outside replay
|
|
80
|
-
// mode. Replay tests run its slices directly.
|
|
81
|
-
if (settings.mode !== 'replay') {
|
|
80
|
+
// mode, where background jobs run. Replay tests run its slices directly.
|
|
81
|
+
if (settings.mode !== 'replay' && settings.backgroundJobs !== false) {
|
|
82
82
|
importer = createImportWorker({ db, runtime: current(), log });
|
|
83
83
|
await importer.start();
|
|
84
84
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// replay no network: recorded, synthetic Xero responses
|
|
12
12
|
// (server/integrations/xero/fixtures). Development and CI.
|
|
13
13
|
|
|
14
|
+
import { backgroundJobsOn } from '../../core/host.js';
|
|
15
|
+
|
|
14
16
|
export const XERO_MODES = Object.freeze(['live', 'sandbox', 'replay']);
|
|
15
17
|
|
|
16
18
|
// Xero's own public endpoints. They are the same for every installation.
|
|
@@ -154,6 +156,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
154
156
|
tenantId,
|
|
155
157
|
tenantName,
|
|
156
158
|
liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
|
|
159
|
+
// Off in a Replit workspace (server/core/host.js): no history read and
|
|
160
|
+
// no writes, whatever XERO_ALLOW_LIVE_WRITES says.
|
|
161
|
+
backgroundJobs: backgroundJobsOn(env),
|
|
157
162
|
dayLimit: readPositiveInteger(env, 'XERO_DAY_LIMIT', DEFAULT_XERO_DAY_LIMIT),
|
|
158
163
|
usageWarningFraction: readFraction(env, 'XERO_USAGE_WARNING_FRACTION', 0.7),
|
|
159
164
|
taxTypes: resolveTaxTypes(env),
|
|
@@ -177,5 +182,6 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
177
182
|
// Whether this installation may write to Xero now. Reads always may.
|
|
178
183
|
export function writesAllowed(settings) {
|
|
179
184
|
if (!settings) return false;
|
|
185
|
+
if (settings.backgroundJobs === false) return false;
|
|
180
186
|
return settings.mode !== 'live' || settings.liveWrites === true;
|
|
181
187
|
}
|
package/server/ui/app.js
CHANGED
|
@@ -13,7 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
-
import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
16
|
+
import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
17
17
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
18
18
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
19
19
|
import { openSearch } from '/assets/ui/search.js';
|
|
@@ -59,6 +59,7 @@ function mountClerk(kind, element, options = {}) {
|
|
|
59
59
|
// The "Manage account" modal does not inherit the button's appearance.
|
|
60
60
|
if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
|
|
61
61
|
if (kind === 'signIn') Clerk.mountSignIn(element, props);
|
|
62
|
+
else if (kind === 'signUp') Clerk.mountSignUp(element, props);
|
|
62
63
|
else if (kind === 'userButton') Clerk.mountUserButton(element, props);
|
|
63
64
|
else if (kind === 'profile') Clerk.mountUserProfile(element, props);
|
|
64
65
|
mounted.set(element, { kind, options });
|
|
@@ -69,6 +70,7 @@ function unmountClerk(element) {
|
|
|
69
70
|
if (!entry) return;
|
|
70
71
|
const Clerk = window.Clerk;
|
|
71
72
|
if (entry.kind === 'signIn') Clerk.unmountSignIn(element);
|
|
73
|
+
else if (entry.kind === 'signUp') Clerk.unmountSignUp(element);
|
|
72
74
|
else if (entry.kind === 'userButton') Clerk.unmountUserButton(element);
|
|
73
75
|
else if (entry.kind === 'profile') Clerk.unmountUserProfile(element);
|
|
74
76
|
mounted.delete(element);
|
|
@@ -181,12 +183,13 @@ function statusPill(person) {
|
|
|
181
183
|
if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
|
|
182
184
|
if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
|
|
183
185
|
if (person.invited) return h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Invited');
|
|
184
|
-
|
|
186
|
+
// Active is the usual state, so it shows nothing.
|
|
187
|
+
return null;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
function roleSelect(value, attrs = {}) {
|
|
188
191
|
return h('select', { class: 'fx-select', ...attrs },
|
|
189
|
-
['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value }, ROLE_LABEL[role])));
|
|
192
|
+
['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value, title: ROLE_HINT[role] }, ROLE_LABEL[role])));
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
// ── Views ──────────────────────────────────────────────────────────────────
|
|
@@ -284,6 +287,13 @@ document.addEventListener('keydown', (event) => {
|
|
|
284
287
|
document.addEventListener('click', (event) => {
|
|
285
288
|
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
|
|
286
289
|
});
|
|
290
|
+
// Folded, a click on the sidebar's empty space opens it out again, as in
|
|
291
|
+
// Claude. A click on a page, the account photo or Search still does its job.
|
|
292
|
+
document.addEventListener('click', (event) => {
|
|
293
|
+
if (NARROW.matches || !readFolded() || !event.target.closest?.('.fx-side')) return;
|
|
294
|
+
if (event.target.closest('a, button, input, select, textarea, [role="button"], [role="menuitem"], #user-button')) return;
|
|
295
|
+
toggleMenu();
|
|
296
|
+
});
|
|
287
297
|
|
|
288
298
|
// Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
|
|
289
299
|
// open, invoices and jobs (search.js).
|
|
@@ -363,6 +373,13 @@ function renderSignedOut() {
|
|
|
363
373
|
document.body.dataset.state = 'signed-out';
|
|
364
374
|
document.title = 'Sign in · Flow';
|
|
365
375
|
const panel = $('sign-in');
|
|
376
|
+
// The link in an invitation email (POST /api/people) carries a Clerk
|
|
377
|
+
// ticket: the invitee makes their account here, on that address only.
|
|
378
|
+
if (new URLSearchParams(location.search).has('__clerk_ticket')) {
|
|
379
|
+
$('sign-in-note').textContent = 'Finish your invitation to Flow.';
|
|
380
|
+
mountClerk('signUp', panel, { forceRedirectUrl: '/sign-in', signInUrl: '/sign-in' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
366
383
|
$('sign-in-note').textContent = config.publicSignUp
|
|
367
384
|
? 'Sign in, or ask for access with your work email.'
|
|
368
385
|
: 'Sign in with the account you were invited with.';
|
|
@@ -442,13 +459,12 @@ function renderNoAccess(error) {
|
|
|
442
459
|
// removes. Managing people needs a verified owner sign-in (/api/me says).
|
|
443
460
|
async function renderPeople(main) {
|
|
444
461
|
const canManage = Boolean(me.ownerActions?.allowed);
|
|
445
|
-
const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite
|
|
462
|
+
const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite');
|
|
446
463
|
const tableHost = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
447
464
|
main.append(...[
|
|
448
|
-
pageHeader('People', {
|
|
465
|
+
pageHeader('People', { actions: [invite] }),
|
|
449
466
|
canManage ? null : notice('Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
450
467
|
tableHost,
|
|
451
|
-
h('dl', { class: 'fx-roles' }, ['owner', 'admin', 'staff'].flatMap((role) => [h('dt', {}, ROLE_LABEL[role]), h('dd', {}, ROLE_HINT[role])])),
|
|
452
468
|
].filter(Boolean));
|
|
453
469
|
if (!canManage) {
|
|
454
470
|
tableHost.replaceChildren(h('p', { class: 'fx-empty' }, 'The people list opens after a verified owner sign-in.'));
|
|
@@ -469,12 +485,34 @@ async function refreshPeople(host) {
|
|
|
469
485
|
const rows = people.map((person) => personRow(person, host));
|
|
470
486
|
host.replaceChildren(...[
|
|
471
487
|
waiting ? notice(`${waiting} ${waiting === 1 ? 'person is' : 'people are'} waiting for approval.`, 'info') : null,
|
|
472
|
-
h('table', { class: 'fx-table' },
|
|
473
|
-
h('thead', {}, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', {
|
|
488
|
+
h('table', { class: 'fx-table fx-people' },
|
|
489
|
+
h('thead', { class: 'fx-sr' }, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', {}, 'Actions'))),
|
|
474
490
|
h('tbody', {}, rows)),
|
|
475
491
|
].filter(Boolean));
|
|
476
492
|
}
|
|
477
493
|
|
|
494
|
+
let peopleMenus = 0;
|
|
495
|
+
|
|
496
|
+
// A row's ⋯ menu, as on the Custom fields page.
|
|
497
|
+
function rowMenu(label, items) {
|
|
498
|
+
const id = `fx-people-menu-${++peopleMenus}`;
|
|
499
|
+
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
|
|
500
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
501
|
+
panel.addEventListener('toggle', (event) => {
|
|
502
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
503
|
+
if (event.newState !== 'open') return;
|
|
504
|
+
const close = () => panel.hidePopover();
|
|
505
|
+
panel.replaceChildren(...items.map((entry) => (entry === '-'
|
|
506
|
+
? h('div', { class: 'fx-pop__sep' })
|
|
507
|
+
: h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
|
|
508
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
|
|
509
|
+
const at = button.getBoundingClientRect();
|
|
510
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
511
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
512
|
+
});
|
|
513
|
+
return [button, panel];
|
|
514
|
+
}
|
|
515
|
+
|
|
478
516
|
function personRow(person, host) {
|
|
479
517
|
const self = person.id === me.person.id;
|
|
480
518
|
const act = async (fn, done) => {
|
|
@@ -493,24 +531,28 @@ function personRow(person, host) {
|
|
|
493
531
|
h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
|
|
494
532
|
person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
|
|
495
533
|
const role = self
|
|
496
|
-
? h('span', {}, ROLE_LABEL[person.role])
|
|
534
|
+
? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
|
|
497
535
|
: roleSelect(person.role, {
|
|
498
536
|
'aria-label': `Role for ${person.email}`,
|
|
537
|
+
title: ROLE_HINT[person.role],
|
|
499
538
|
onchange: (event) => act(() => patch({ role: event.target.value }), `${person.email} is now ${ROLE_LABEL[event.target.value].toLowerCase()}.`),
|
|
500
539
|
});
|
|
501
540
|
const actions = [];
|
|
502
541
|
if (!self) {
|
|
503
542
|
if (person.status === 'pending') {
|
|
504
543
|
actions.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} can sign in.`) }, 'Approve'));
|
|
505
|
-
} else if (person.status === 'active') {
|
|
506
|
-
actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) }, 'Turn off'));
|
|
507
|
-
} else {
|
|
508
|
-
actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) }, 'Turn on'));
|
|
509
544
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
545
|
+
const items = [];
|
|
546
|
+
if (person.invited && person.status === 'active') {
|
|
547
|
+
items.push({ icon: 'mail', text: 'Email invite again', run: () => act(() => flow(`/api/people/${person.id}/invite`, { method: 'POST' }), `Invite emailed to ${person.email}.`) });
|
|
548
|
+
}
|
|
549
|
+
if (person.status === 'active') items.push({ icon: 'circle-off', text: 'Turn off', run: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) });
|
|
550
|
+
if (person.status === 'disabled') items.push({ icon: 'circle-check', text: 'Turn on', run: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) });
|
|
551
|
+
items.push('-', {
|
|
552
|
+
icon: 'trash-2',
|
|
553
|
+
text: 'Remove',
|
|
554
|
+
tone: 'danger',
|
|
555
|
+
run: async () => {
|
|
514
556
|
const ok = await dialog({
|
|
515
557
|
title: `Remove ${person.email}?`,
|
|
516
558
|
body: h('p', {}, 'They can no longer sign in to Flow. Records they made stay.'),
|
|
@@ -519,7 +561,8 @@ function personRow(person, host) {
|
|
|
519
561
|
});
|
|
520
562
|
if (ok) await act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' }), `${person.email} is removed.`);
|
|
521
563
|
},
|
|
522
|
-
}
|
|
564
|
+
});
|
|
565
|
+
actions.push(...rowMenu(`More for ${person.email}`, items));
|
|
523
566
|
}
|
|
524
567
|
return h('tr', {},
|
|
525
568
|
h('td', {}, who),
|
|
@@ -907,7 +950,27 @@ function xeroConnection(organisations, canChange) {
|
|
|
907
950
|
return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
|
|
908
951
|
}
|
|
909
952
|
|
|
953
|
+
// When Clerk cannot email an invitation, the owner sends the invitee this
|
|
954
|
+
// message instead. It names the sign-in address and the email to use.
|
|
955
|
+
function inviteMessage(person) {
|
|
956
|
+
return [
|
|
957
|
+
'You have access to Flow.',
|
|
958
|
+
`Sign in at ${location.origin}/sign-in with ${person.email}.`,
|
|
959
|
+
'Use Continue with Google if that address is a Google account.',
|
|
960
|
+
].join('\n');
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function copyInvite(person) {
|
|
964
|
+
try {
|
|
965
|
+
await navigator.clipboard.writeText(inviteMessage(person));
|
|
966
|
+
return true;
|
|
967
|
+
} catch {
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
910
972
|
async function invitePerson() {
|
|
973
|
+
const hint = h('p', { class: 'fx-dim' }, ROLE_HINT.staff);
|
|
911
974
|
const data = await dialog({
|
|
912
975
|
title: 'Invite someone',
|
|
913
976
|
body: h('div', { class: 'fx-fields' },
|
|
@@ -916,14 +979,16 @@ async function invitePerson() {
|
|
|
916
979
|
h('input', { class: 'fx-input', name: 'email', type: 'email', required: true, autocomplete: 'off', placeholder: 'name@example.com' })),
|
|
917
980
|
h('label', { class: 'fx-field' },
|
|
918
981
|
h('span', { class: 'fx-label' }, 'Role'),
|
|
919
|
-
roleSelect('staff', { name: 'role' })),
|
|
920
|
-
|
|
921
|
-
confirm: 'Send
|
|
982
|
+
roleSelect('staff', { name: 'role', onchange: (event) => { hint.textContent = ROLE_HINT[event.target.value]; } })),
|
|
983
|
+
hint),
|
|
984
|
+
confirm: 'Send invite',
|
|
922
985
|
});
|
|
923
986
|
if (!data) return;
|
|
924
987
|
try {
|
|
925
|
-
await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
|
|
926
|
-
toast(
|
|
988
|
+
const { person, invite } = await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
|
|
989
|
+
if (invite?.emailed) toast(`Invite emailed to ${person.email}.`, 'success');
|
|
990
|
+
else if (await copyInvite(person)) toast(`${person.email} is invited, but the email could not be sent. A sign-in message is copied: paste it into an email to them.`, 'info');
|
|
991
|
+
else toast(`${person.email} is invited, but the email could not be sent. Send them: ${inviteMessage(person)}`, 'info');
|
|
927
992
|
} catch (error) {
|
|
928
993
|
toast(error.message, 'danger');
|
|
929
994
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The bulk action bar: while a person has ticked rows in a list, it floats
|
|
2
|
+
// at the foot of the page, like ClickUp's. It shows "N selected ×" (the ×
|
|
3
|
+
// clears the ticks), then the actions in groups split by a thin line, then
|
|
4
|
+
// More. An action has a Lucide icon and one word; an icon-only action gives
|
|
5
|
+
// its name on hover and to a screen reader. More opens every action, with a
|
|
6
|
+
// search box, grouped under small heads.
|
|
7
|
+
//
|
|
8
|
+
// bulkBar(ui) answers { element, draw(state) }. state = { count, onClear,
|
|
9
|
+
// groups: [[action]], more: [{ head, actions: [action] }] }; an action is
|
|
10
|
+
// { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
|
|
11
|
+
// long name, for the hover tip and the More menu. A count of 0 hides the
|
|
12
|
+
// bar; Esc clears the ticks while it shows.
|
|
13
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
14
|
+
|
|
15
|
+
let barCount = 0;
|
|
16
|
+
|
|
17
|
+
export function bulkBar(ui) {
|
|
18
|
+
const { h } = ui;
|
|
19
|
+
const id = `fx-bulk-more-${++barCount}`;
|
|
20
|
+
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': 'Ticked invoices', hidden: true });
|
|
21
|
+
const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
|
|
22
|
+
let current = null;
|
|
23
|
+
let busy = false;
|
|
24
|
+
|
|
25
|
+
const run = async (action) => {
|
|
26
|
+
if (busy) return;
|
|
27
|
+
busy = true;
|
|
28
|
+
element.setAttribute('aria-busy', 'true');
|
|
29
|
+
try { await action.run(); } finally {
|
|
30
|
+
busy = false;
|
|
31
|
+
element.removeAttribute('aria-busy');
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const button = (action) => h('button', {
|
|
36
|
+
type: 'button',
|
|
37
|
+
class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
|
|
38
|
+
...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
39
|
+
...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
|
|
40
|
+
onclick: () => run(action),
|
|
41
|
+
}, lucide(h, action.icon), action.iconOnly ? null : h('span', { class: 'fx-bulk__word' }, action.label));
|
|
42
|
+
|
|
43
|
+
// More: a search box over every action, under small heads.
|
|
44
|
+
const search = h('input', { class: 'fx-input fx-bulk__search', type: 'search', placeholder: 'Search…', 'aria-label': 'Search actions' });
|
|
45
|
+
const list = h('div', { class: 'fx-bulk__list' });
|
|
46
|
+
const drawMore = () => {
|
|
47
|
+
const q = search.value.trim().toLowerCase();
|
|
48
|
+
const parts = [];
|
|
49
|
+
for (const { head, actions } of current?.more ?? []) {
|
|
50
|
+
const found = actions.filter((a) => !q || `${a.label} ${a.title ?? ''}`.toLowerCase().includes(q));
|
|
51
|
+
if (!found.length) continue;
|
|
52
|
+
parts.push(h('p', { class: 'fx-pop__head' }, head), h('div', { class: 'fx-bulk__grid' }, found.map((action) => h('button', {
|
|
53
|
+
type: 'button', class: 'fx-pop__item', role: 'menuitem', ...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
54
|
+
onclick: () => { panel.hidePopover?.(); run(action); },
|
|
55
|
+
}, h('span', { class: 'fx-pop__tick' }, lucide(h, action.icon)), h('span', {}, action.title ?? action.label)))));
|
|
56
|
+
}
|
|
57
|
+
list.replaceChildren(...(parts.length ? parts : [h('p', { class: 'fx-bulk__none' }, 'No action matches.')]));
|
|
58
|
+
};
|
|
59
|
+
search.addEventListener('input', drawMore);
|
|
60
|
+
const onKey = (event) => {
|
|
61
|
+
if (!element.isConnected) { document.removeEventListener('keydown', onKey); return; }
|
|
62
|
+
if (event.key === 'Escape' && !element.hidden && !panel.matches?.(':popover-open') && !document.querySelector('dialog[open]')) current?.onClear();
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener?.('keydown', onKey);
|
|
65
|
+
panel.append(search, list);
|
|
66
|
+
const more = h('button', {
|
|
67
|
+
type: 'button', class: 'fx-bulk__act', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false',
|
|
68
|
+
}, lucide(h, 'ellipsis'), h('span', { class: 'fx-bulk__word' }, 'More'));
|
|
69
|
+
// The menu opens above the bar, at its right edge.
|
|
70
|
+
panel.addEventListener('toggle', (event) => {
|
|
71
|
+
more.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
72
|
+
if (event.newState !== 'open') return;
|
|
73
|
+
search.value = '';
|
|
74
|
+
drawMore();
|
|
75
|
+
const at = more.getBoundingClientRect();
|
|
76
|
+
panel.style.top = `${Math.round(Math.max(8, at.top - panel.offsetHeight - 8))}px`;
|
|
77
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
78
|
+
search.focus();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function draw(state) {
|
|
82
|
+
current = state;
|
|
83
|
+
element.hidden = !state?.count;
|
|
84
|
+
if (element.hidden) { panel.hidePopover?.(); return; }
|
|
85
|
+
const count = h('span', { class: 'fx-bulk__count' },
|
|
86
|
+
h('span', {}, `${state.count} selected`),
|
|
87
|
+
h('button', { type: 'button', class: 'fx-bulk__clear', 'aria-label': 'Clear the ticks', 'data-tip': 'Clear', onclick: state.onClear }, lucide(h, 'x')));
|
|
88
|
+
const groups = state.groups.filter((g) => g.length);
|
|
89
|
+
const parts = [];
|
|
90
|
+
groups.forEach((group, i) => {
|
|
91
|
+
if (i) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }));
|
|
92
|
+
parts.push(...group.map(button));
|
|
93
|
+
});
|
|
94
|
+
if ((state.more ?? []).some((g) => g.actions.length)) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }), more, panel);
|
|
95
|
+
element.replaceChildren(count, h('span', { class: 'fx-bulk__acts' }, ...parts));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { element, draw };
|
|
99
|
+
}
|