@arcforgelabs/flow 2026.10.46 → 2026.10.47
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +56 -16
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/sentry/index.js +14 -7
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +105 -25
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +235 -24
- package/server/ui/invoicing.js +254 -55
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +198 -43
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/server/ui/jobs.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// (docs/JOB_BOARD_MOVES.md). Grouped by a custom field, the move sets the
|
|
10
10
|
// job's value, in Flow only. Grouped by status, the move is a stage move
|
|
11
11
|
// in the work system: Seazona cannot take one through its API, so Flow
|
|
12
|
-
// shows what to do there and the card waits in its new lane. On a phone
|
|
13
|
-
//
|
|
12
|
+
// shows what to do there and the card waits in its new lane. On a phone a
|
|
13
|
+
// long press opens Move to. With a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
16
|
import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
@@ -18,15 +18,17 @@ import { lucide } from '/assets/ui/custom-fields.js';
|
|
|
18
18
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
19
19
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
20
20
|
import {
|
|
21
|
-
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
21
|
+
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, phoneLaneIndex, PHONE_HOLD_MS, problemSummary, statusRank,
|
|
22
22
|
} from '/assets/ui/job-board.js';
|
|
23
|
+
import { rowMenu } from '/assets/ui/kit.js';
|
|
24
|
+
import { isPhone } from '/assets/ui/mobile.js';
|
|
23
25
|
import {
|
|
24
26
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
25
27
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
26
28
|
} from '/assets/ui/job-view.js';
|
|
27
29
|
import { clipLabel } from '/assets/ui/person.js';
|
|
28
30
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
29
|
-
import { sidecar } from '/assets/ui/sidecar.js';
|
|
31
|
+
import { recordBar, sidecar } from '/assets/ui/sidecar.js';
|
|
30
32
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
31
33
|
|
|
32
34
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
@@ -38,7 +40,7 @@ const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
|
38
40
|
// A job's events (server/core/ledger.js), in plain words.
|
|
39
41
|
const ACTIVITY = {
|
|
40
42
|
observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
|
|
41
|
-
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
|
|
43
|
+
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_deleted: 'Draft invoice deleted', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
|
|
42
44
|
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
43
45
|
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
44
46
|
status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
|
|
@@ -324,6 +326,7 @@ export async function renderJobs(main, ui) {
|
|
|
324
326
|
|
|
325
327
|
function draw() {
|
|
326
328
|
if (!state.jobs.length) {
|
|
329
|
+
boardTabs.hidden = true;
|
|
327
330
|
main.classList.remove('fx-main--board');
|
|
328
331
|
host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
|
|
329
332
|
h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
|
|
@@ -339,6 +342,7 @@ export async function renderJobs(main, ui) {
|
|
|
339
342
|
for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
|
|
340
343
|
state.found = found;
|
|
341
344
|
frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
|
|
345
|
+
if (state.view.layout !== 'board') boardTabs.hidden = true;
|
|
342
346
|
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
343
347
|
drawBulk();
|
|
344
348
|
}
|
|
@@ -389,7 +393,7 @@ export async function renderJobs(main, ui) {
|
|
|
389
393
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
390
394
|
const card = h('article', {
|
|
391
395
|
class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
|
|
392
|
-
onclick: () => { if (
|
|
396
|
+
onclick: () => { if (dragged) { dragged = false; return; } openJob(job); },
|
|
393
397
|
onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
|
|
394
398
|
},
|
|
395
399
|
h('div', { class: 'fx-job__client' }, clientOf(job)),
|
|
@@ -450,19 +454,108 @@ export async function renderJobs(main, ui) {
|
|
|
450
454
|
const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
|
|
451
455
|
|
|
452
456
|
function board(groups) {
|
|
453
|
-
if (!groups.length)
|
|
457
|
+
if (!groups.length) {
|
|
458
|
+
boardTabs.hidden = true;
|
|
459
|
+
return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
|
|
460
|
+
}
|
|
454
461
|
const element = h('div', { class: 'fx-board' }, groups.map(({ label, jobs, rank, color }, index) => h('section', { class: 'fx-board__col', 'aria-label': label, 'data-lane': String(index) },
|
|
455
462
|
h('header', { class: 'fx-board__head' },
|
|
456
463
|
color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
|
|
457
464
|
h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
|
|
458
465
|
h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
|
|
459
466
|
if (groups.some((g) => g.drop)) enableDrag(element, groups);
|
|
467
|
+
phoneLanes(element, groups);
|
|
460
468
|
// Lanes past the frame's sides, and cards past a lane's foot, fade.
|
|
461
469
|
edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
|
|
462
470
|
element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
|
|
463
471
|
return element;
|
|
464
472
|
}
|
|
465
473
|
|
|
474
|
+
// Stage tabs for the phone: one per lane, with its count. A tap brings
|
|
475
|
+
// that lane across; a swipe updates the tab. Hidden on a wider screen.
|
|
476
|
+
const boardTabs = h('div', { class: 'fx-board-tabs', role: 'tablist', 'aria-label': 'Stages', hidden: true });
|
|
477
|
+
function phoneLanes(element, groups) {
|
|
478
|
+
const bar = main.querySelector('.fx-toolbar');
|
|
479
|
+
if (bar && !boardTabs.isConnected) bar.prepend(boardTabs);
|
|
480
|
+
const buttons = groups.map((group, index) => {
|
|
481
|
+
const count = group.jobs.length;
|
|
482
|
+
const name = `${group.label}, ${count} ${count === 1 ? 'job' : 'jobs'}`;
|
|
483
|
+
return h('button', {
|
|
484
|
+
type: 'button', role: 'tab', id: `fx-lane-tab-${index}`,
|
|
485
|
+
'aria-controls': `fx-lane-${index}`, 'aria-selected': 'false', title: name,
|
|
486
|
+
onclick: () => element.scrollTo({ left: element.querySelector(`#fx-lane-${index}`)?.offsetLeft ?? 0, behavior: 'smooth' }),
|
|
487
|
+
}, h('span', {}, group.label), h('span', { class: 'fx-tabs__count' }, String(count)));
|
|
488
|
+
});
|
|
489
|
+
boardTabs.replaceChildren(...buttons);
|
|
490
|
+
boardTabs.hidden = false;
|
|
491
|
+
const cols = [...element.querySelectorAll(':scope > .fx-board__col')];
|
|
492
|
+
cols.forEach((col, index) => {
|
|
493
|
+
col.id = `fx-lane-${index}`;
|
|
494
|
+
col.setAttribute('role', 'tabpanel');
|
|
495
|
+
col.setAttribute('aria-labelledby', `fx-lane-tab-${index}`);
|
|
496
|
+
});
|
|
497
|
+
let shown = -1;
|
|
498
|
+
const mark = () => {
|
|
499
|
+
const at = phoneLaneIndex(element.scrollLeft, element.clientWidth, cols.length);
|
|
500
|
+
if (at === shown) return;
|
|
501
|
+
shown = at;
|
|
502
|
+
buttons.forEach((button, index) => {
|
|
503
|
+
const on = index === at;
|
|
504
|
+
button.setAttribute('aria-selected', String(on));
|
|
505
|
+
if (on) button.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
element.addEventListener('scroll', () => requestAnimationFrame(mark), { passive: true });
|
|
509
|
+
mark();
|
|
510
|
+
enableHold(element, groups);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// A long press on a phone opens Move to. A drag stays a mouse or pen
|
|
514
|
+
// move on a wider screen (enableDrag). A small move cancels the press,
|
|
515
|
+
// so a swipe still changes lane.
|
|
516
|
+
function enableHold(element, groups) {
|
|
517
|
+
element.addEventListener('pointerdown', (event) => {
|
|
518
|
+
if (!isPhone() || event.pointerType !== 'touch' || event.button !== 0) return;
|
|
519
|
+
const card = event.target.closest('.fx-job');
|
|
520
|
+
if (!card || event.target.closest('button, a')) return;
|
|
521
|
+
const startX = event.clientX;
|
|
522
|
+
const startY = event.clientY;
|
|
523
|
+
let held = false;
|
|
524
|
+
const timer = setTimeout(() => {
|
|
525
|
+
held = true;
|
|
526
|
+
dragged = true;
|
|
527
|
+
const from = Number(card.closest('.fx-board__col')?.dataset.lane);
|
|
528
|
+
openMoveSheet(cardJobs.get(card), groups, from);
|
|
529
|
+
}, PHONE_HOLD_MS);
|
|
530
|
+
// The release may land outside the card (a tap still ends on the page),
|
|
531
|
+
// so the press is watched on the document.
|
|
532
|
+
const stop = (end) => {
|
|
533
|
+
if (end.type === 'pointermove' && Math.hypot(end.clientX - startX, end.clientY - startY) <= 10) return;
|
|
534
|
+
clearTimeout(timer);
|
|
535
|
+
document.removeEventListener('pointermove', stop);
|
|
536
|
+
document.removeEventListener('pointerup', stop);
|
|
537
|
+
document.removeEventListener('pointercancel', stop);
|
|
538
|
+
if (held) setTimeout(() => { dragged = false; }, 0);
|
|
539
|
+
};
|
|
540
|
+
document.addEventListener('pointermove', stop);
|
|
541
|
+
document.addEventListener('pointerup', stop);
|
|
542
|
+
document.addEventListener('pointercancel', stop);
|
|
543
|
+
card.addEventListener('contextmenu', (menu) => { if (held) menu.preventDefault(); }, { once: true });
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function openMoveSheet(job, groups, from) {
|
|
548
|
+
const choices = groups.flatMap((group, index) => {
|
|
549
|
+
if (index === from || !group.drop) return [];
|
|
550
|
+
if (group.drop.kind !== 'field' && !movesStages(job)) return [];
|
|
551
|
+
return [{ label: group.label, value: String(index), detail: group.jobs.length }];
|
|
552
|
+
});
|
|
553
|
+
if (!choices.length || !job) return;
|
|
554
|
+
const picked = await ui.dialog({ title: 'Move to', choices });
|
|
555
|
+
const target = picked == null ? null : groups[Number(picked)];
|
|
556
|
+
if (target?.drop) moveJob(job, target.drop);
|
|
557
|
+
}
|
|
558
|
+
|
|
466
559
|
// ── Moves ────────────────────────────────────────────────────────────────
|
|
467
560
|
// Drag with a mouse or pen: press on a card, move 6px, and a copy of the
|
|
468
561
|
// card follows the pointer. A lane that takes the card lights up. Touch
|
|
@@ -486,7 +579,7 @@ export async function renderJobs(main, ui) {
|
|
|
486
579
|
drag = null;
|
|
487
580
|
};
|
|
488
581
|
element.addEventListener('pointerdown', (event) => {
|
|
489
|
-
if (event.button !== 0 || event.pointerType === 'touch') return;
|
|
582
|
+
if (event.button !== 0 || event.pointerType === 'touch' || isPhone()) return;
|
|
490
583
|
const card = event.target.closest('.fx-job');
|
|
491
584
|
if (!card || event.target.closest('button, a')) return;
|
|
492
585
|
const rect = card.getBoundingClientRect();
|
|
@@ -1091,12 +1184,15 @@ export async function renderJobs(main, ui) {
|
|
|
1091
1184
|
|
|
1092
1185
|
// The job's page: a large panel over the board, like a task page. A
|
|
1093
1186
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
1094
|
-
// and its lines. At the side
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1187
|
+
// and its lines. At the side, the sidecar: Activity, and Fix when the job
|
|
1188
|
+
// has a problem (for a changed job: what changed, and Accept change). The
|
|
1189
|
+
// sidecar closes, and the main area takes its room. On a phone the same
|
|
1190
|
+
// panel is the whole screen: back (the board stays where it was scrolled),
|
|
1191
|
+
// the client, the previous and next job, a menu, the sidecar's tabs under
|
|
1192
|
+
// the bar, and the main action fixed at the foot. Esc, the X or a click
|
|
1193
|
+
// outside closes the panel. `atFix` opens the Fix tab on the first problem
|
|
1194
|
+
// (or scrolls to the invoice choice). The job's custom fields can be
|
|
1195
|
+
// changed here (in Flow only), and its stage moved (a hand-off to the
|
|
1100
1196
|
// work system).
|
|
1101
1197
|
function openJob(job, { atFix = false } = {}) {
|
|
1102
1198
|
const o = job.obligation;
|
|
@@ -1131,11 +1227,9 @@ export async function renderJobs(main, ui) {
|
|
|
1131
1227
|
class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
|
|
1132
1228
|
}, lucide(h, 'triangle-alert'), f.label));
|
|
1133
1229
|
function toggleFix(i) {
|
|
1134
|
-
if (picked === i && side.tab() === 'fix') { side.close(); return; }
|
|
1230
|
+
if (!isPhone() && picked === i && side.tab() === 'fix') { side.close(); return; }
|
|
1135
1231
|
picked = i;
|
|
1136
1232
|
side.open('fix');
|
|
1137
|
-
// On a phone the sidecar is below the job: bring it into view.
|
|
1138
|
-
if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
|
|
1139
1233
|
}
|
|
1140
1234
|
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1141
1235
|
// possible matches are compared on the Reconcile page, not here.
|
|
@@ -1221,6 +1315,7 @@ export async function renderJobs(main, ui) {
|
|
|
1221
1315
|
}, lucide(h, 'panel-right'));
|
|
1222
1316
|
const side = sidecar(h, {
|
|
1223
1317
|
label: 'Job side panel',
|
|
1318
|
+
phone: 'page',
|
|
1224
1319
|
tabs: [
|
|
1225
1320
|
{ id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
|
|
1226
1321
|
hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
|
|
@@ -1233,7 +1328,40 @@ export async function renderJobs(main, ui) {
|
|
|
1233
1328
|
},
|
|
1234
1329
|
});
|
|
1235
1330
|
side.element.classList.add('fx-task__side');
|
|
1236
|
-
|
|
1331
|
+
const jobs = state.found ?? [];
|
|
1332
|
+
const at = jobs.findIndex((item) => jobKey(item) === jobKey(job));
|
|
1333
|
+
// Back closes the page. The board is still underneath, at the same scroll.
|
|
1334
|
+
const stepTo = (delta) => {
|
|
1335
|
+
const other = jobs[at + delta];
|
|
1336
|
+
if (!other) return;
|
|
1337
|
+
const board = document.querySelector('.fx-board');
|
|
1338
|
+
const frame = document.querySelector('.fx-frame__body');
|
|
1339
|
+
const saved = { y: window.scrollY, frame: frame?.scrollTop ?? 0, board: board?.scrollLeft ?? 0 };
|
|
1340
|
+
close();
|
|
1341
|
+
openJob(other);
|
|
1342
|
+
const restore = () => {
|
|
1343
|
+
window.scrollTo(0, saved.y);
|
|
1344
|
+
const nextFrame = document.querySelector('.fx-frame__body');
|
|
1345
|
+
const nextBoard = document.querySelector('.fx-board');
|
|
1346
|
+
if (nextFrame) nextFrame.scrollTop = saved.frame;
|
|
1347
|
+
if (nextBoard) nextBoard.scrollLeft = saved.board;
|
|
1348
|
+
};
|
|
1349
|
+
restore();
|
|
1350
|
+
requestAnimationFrame(restore);
|
|
1351
|
+
};
|
|
1352
|
+
const menuItems = [];
|
|
1353
|
+
if (choose && o?.id) menuItems.push({ icon: 'git-compare', text: 'Reconcile', run: () => { close(); ui.go(`/reconcile/${o.id}`); } });
|
|
1354
|
+
if (job.link?.url) menuItems.push({ icon: 'external-link', text: `Open in ${system}`, run: () => window.open(job.link.url, '_blank', 'noopener') });
|
|
1355
|
+
const menu = menuItems.length ? rowMenu('More', menuItems, { className: 'fx-record__menu' }) : null;
|
|
1356
|
+
const bar = recordBar(h, {
|
|
1357
|
+
title: clientOf(job),
|
|
1358
|
+
label: 'Job',
|
|
1359
|
+
onBack: close,
|
|
1360
|
+
prev: { label: 'Previous job', disabled: at <= 0, onclick: () => stepTo(-1) },
|
|
1361
|
+
next: { label: 'Next job', disabled: at < 0 || at >= jobs.length - 1, onclick: () => stepTo(1) },
|
|
1362
|
+
menu: menu?.[0] ?? null,
|
|
1363
|
+
});
|
|
1364
|
+
element.append(bar, h('div', { class: 'fx-task__grid' },
|
|
1237
1365
|
h('div', { class: 'fx-task__main' },
|
|
1238
1366
|
h('header', { class: 'fx-task__head' },
|
|
1239
1367
|
h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
|
|
@@ -1276,12 +1404,15 @@ export async function renderJobs(main, ui) {
|
|
|
1276
1404
|
: h('p', { class: 'fx-dim' }, 'No lines.')),
|
|
1277
1405
|
h('div', { class: 'fx-task__actions' }, actions(job, close))),
|
|
1278
1406
|
side.element));
|
|
1407
|
+
if (menu) element.append(menu[1]);
|
|
1279
1408
|
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
1280
1409
|
element.addEventListener('close', () => element.remove());
|
|
1281
1410
|
document.body.append(element);
|
|
1282
1411
|
element.showModal();
|
|
1283
1412
|
const last = readSide();
|
|
1284
|
-
|
|
1413
|
+
// On a phone the tabs are the page, so the sidecar stays open.
|
|
1414
|
+
if (isPhone()) side.open((atFix || last === 'fix') && hasFix ? 'fix' : 'activity');
|
|
1415
|
+
else if (atFix && hasFix) toggleFix(0);
|
|
1285
1416
|
else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
|
|
1286
1417
|
if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
|
|
1287
1418
|
if (!o) return;
|
package/server/ui/kit.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// functions, so a card is the real thing. Reuse one or give it an option;
|
|
6
6
|
// never copy it into a page (AGENTS.md, "UI: reuse first").
|
|
7
7
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
8
|
+
import { applyPhoneRule } from './mobile.js';
|
|
8
9
|
import { named } from '/assets/ui/tip.js';
|
|
9
10
|
|
|
10
11
|
// h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
|
|
@@ -53,12 +54,12 @@ export function holdButton(button, ms) {
|
|
|
53
54
|
// `count` is a live number beside the name (2 to check); `help` is a docs
|
|
54
55
|
// link, an icon with its name as a tip.
|
|
55
56
|
export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
|
|
56
|
-
return h('header', { class: 'fx-ph' },
|
|
57
|
+
return applyPhoneRule(h('header', { class: 'fx-ph' },
|
|
57
58
|
h('div', { class: 'fx-ph__text' },
|
|
58
59
|
h('h1', { class: 'fx-title' }, title),
|
|
59
60
|
count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
|
|
60
61
|
help ? helpLink(help) : null),
|
|
61
|
-
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
62
|
+
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null), 'pageHeader');
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
|
|
@@ -77,8 +78,8 @@ export function listHead(title, { count = null, help = null, actions = [], start
|
|
|
77
78
|
const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
|
|
78
79
|
const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
|
|
79
80
|
const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
|
|
80
|
-
const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
|
|
81
|
-
h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
|
|
81
|
+
const bar = applyPhoneRule(h('div', { class: 'fx-listhead', 'data-sticky': sticky },
|
|
82
|
+
h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions))), 'listHead');
|
|
82
83
|
const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
|
|
83
84
|
if (edge && typeof IntersectionObserver === 'function') {
|
|
84
85
|
const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
|
|
@@ -99,7 +100,7 @@ export function listFrame(main, content) {
|
|
|
99
100
|
const tools = h('div', { class: 'fx-frame__tools' });
|
|
100
101
|
const body = h('div', { class: 'fx-frame__body' }, content);
|
|
101
102
|
const view = h('div', { class: 'fx-frame__view' }, body);
|
|
102
|
-
const element = h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools));
|
|
103
|
+
const element = applyPhoneRule(h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools)), 'listFrame');
|
|
103
104
|
edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
|
|
104
105
|
return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
|
|
105
106
|
}
|
|
@@ -149,9 +150,9 @@ export function sectionTitle(text, help = null) {
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
export function notice(text, tone = 'info', title = null) {
|
|
152
|
-
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
153
|
+
return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
153
154
|
title ? h('strong', {}, title) : null,
|
|
154
|
-
h('span', {}, text));
|
|
155
|
+
h('span', {}, text)), 'notice');
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
// A field that fails a check glows red, like a required field, until the
|
|
@@ -159,37 +160,63 @@ export function notice(text, tone = 'info', title = null) {
|
|
|
159
160
|
// `on` false clears it. Answers the field.
|
|
160
161
|
export function fieldError(field, on = true) {
|
|
161
162
|
if (!on) { field.removeAttribute('aria-invalid'); return field; }
|
|
163
|
+
applyPhoneRule(field, 'fieldError');
|
|
162
164
|
field.setAttribute('aria-invalid', 'true');
|
|
163
165
|
field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
|
|
164
166
|
return field;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
// A short result after an action. With `stay: true` it stays until the person
|
|
170
|
+
// closes it (for a danger a failed press must not pass unseen).
|
|
171
|
+
export function toast(text, tone = 'info', { stay = false } = {}) {
|
|
168
172
|
const region = document.getElementById('toasts');
|
|
169
|
-
const item = h('div', {
|
|
173
|
+
const item = applyPhoneRule(h('div', {
|
|
174
|
+
class: stay ? 'fx-toast fx-toast--stay' : 'fx-toast',
|
|
175
|
+
'data-tone': tone,
|
|
176
|
+
role: tone === 'danger' ? 'alert' : 'status',
|
|
177
|
+
}, h('span', { class: 'fx-toast__text' }, text)), 'toast');
|
|
178
|
+
if (stay) {
|
|
179
|
+
item.append(h('button', {
|
|
180
|
+
type: 'button', class: 'fx-toast__close', 'aria-label': 'Close', title: 'Close',
|
|
181
|
+
onclick: () => item.remove(),
|
|
182
|
+
}, lucide(h, 'x')));
|
|
183
|
+
} else {
|
|
184
|
+
setTimeout(() => item.remove(), 4200);
|
|
185
|
+
}
|
|
170
186
|
region.append(item);
|
|
171
|
-
setTimeout(() => item.remove(), 4200);
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
// A modal built on <dialog>: resolves with the button's value, or null.
|
|
175
|
-
export function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
190
|
+
export function dialog({ title, body, confirm, tone = 'primary', choices = null }) {
|
|
176
191
|
return new Promise((resolve) => {
|
|
192
|
+
const choiceList = choices
|
|
193
|
+
? h('div', { class: 'fx-sheet__choices', role: 'group', 'aria-label': title },
|
|
194
|
+
choices.map((choice) => h('button', { class: 'fx-sheet__choice', type: 'submit', value: choice.value },
|
|
195
|
+
h('span', {}, choice.label),
|
|
196
|
+
choice.detail == null ? null : h('span', { class: 'fx-tabs__count' }, String(choice.detail)))))
|
|
197
|
+
: null;
|
|
198
|
+
const actions = choices ? null : h('div', { class: 'fx-dialog__actions' },
|
|
199
|
+
h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
|
|
200
|
+
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm));
|
|
177
201
|
const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
|
|
178
202
|
h('h2', { class: 'fx-dialog__title' }, title),
|
|
179
|
-
body,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
|
|
183
|
-
const element = h('dialog', { class: 'fx-dialog' }, form);
|
|
203
|
+
choiceList ?? body,
|
|
204
|
+
actions);
|
|
205
|
+
const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
|
|
184
206
|
element.addEventListener('close', () => {
|
|
185
|
-
const
|
|
186
|
-
const data = ok ? new FormData(form) : null;
|
|
207
|
+
const value = element.returnValue;
|
|
187
208
|
element.remove();
|
|
188
|
-
|
|
209
|
+
if (choices) {
|
|
210
|
+
resolve(value && value !== 'cancel' ? value : null);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const ok = value === 'ok';
|
|
214
|
+
resolve(ok ? new FormData(form) : null);
|
|
189
215
|
});
|
|
216
|
+
if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
|
|
190
217
|
document.body.append(element);
|
|
191
218
|
element.showModal();
|
|
192
|
-
element.querySelector('input, select, button[value="ok"]')?.focus();
|
|
219
|
+
element.querySelector(choices ? '.fx-sheet__choice' : 'input, select, button[value="ok"]')?.focus();
|
|
193
220
|
});
|
|
194
221
|
}
|
|
195
222
|
|
|
@@ -200,11 +227,11 @@ export function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
|
200
227
|
export function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
201
228
|
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
202
229
|
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
203
|
-
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
230
|
+
return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
204
231
|
h('div', { class: 'fx-setting__text' },
|
|
205
232
|
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
206
233
|
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
207
|
-
h('div', { class: 'fx-setting__control' }, control));
|
|
234
|
+
h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
|
|
208
235
|
}
|
|
209
236
|
settingRow.next = 0;
|
|
210
237
|
|
|
@@ -212,26 +239,126 @@ settingRow.next = 0;
|
|
|
212
239
|
|
|
213
240
|
let rowMenus = 0;
|
|
214
241
|
|
|
242
|
+
function menuGlyph(icon) {
|
|
243
|
+
if (icon instanceof Node) return icon.isConnected ? icon.cloneNode(true) : icon;
|
|
244
|
+
return icon ? lucide(h, icon) : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
215
247
|
// A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
|
|
216
248
|
// button's right edge; `align: 'start'` lines it up with its left edge.
|
|
217
|
-
// `
|
|
218
|
-
|
|
249
|
+
// `place: 'side'` floats it to the right of the button (the sidebar's More),
|
|
250
|
+
// and moves focus into the menu and back to the button on close.
|
|
251
|
+
// `trigger` swaps the ⋯ icon for the given contents.
|
|
252
|
+
// An item is `{ icon, text, run, tone }` or `'-'` (a line). `icon` is a Lucide
|
|
253
|
+
// name or a node. `trailing` is a control at the row's end (the pin) that
|
|
254
|
+
// does not close the menu: `{ icon, label, pressed, run, locked }`. `locked`
|
|
255
|
+
// draws the icon and does not take a click. `items` may be a function, read
|
|
256
|
+
// again each time the menu opens. `panel.refresh()` redraws it while open.
|
|
257
|
+
export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end', place = 'below' } = {}) {
|
|
219
258
|
const id = `fx-row-menu-${++rowMenus}`;
|
|
220
|
-
const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
|
|
221
|
-
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
259
|
+
const button = applyPhoneRule(h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis')), 'rowMenu');
|
|
260
|
+
const panel = h('div', { class: place === 'side' ? 'fx-pop fx-pop--side' : 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
261
|
+
const entries = () => (typeof items === 'function' ? items() : items);
|
|
262
|
+
let picking = false;
|
|
263
|
+
|
|
264
|
+
function renderEntry(entry) {
|
|
265
|
+
if (entry === '-') return h('div', { class: 'fx-pop__sep' });
|
|
266
|
+
const item = h('button', {
|
|
267
|
+
type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone,
|
|
268
|
+
onclick: () => { picking = true; panel.hidePopover(); entry.run(); },
|
|
269
|
+
}, h('span', { class: 'fx-pop__tick' }, menuGlyph(entry.icon)), h('span', {}, entry.text));
|
|
270
|
+
if (!entry.trailing) return item;
|
|
271
|
+
const trail = entry.trailing;
|
|
272
|
+
const mark = menuGlyph(trail.icon);
|
|
273
|
+
const control = trail.locked
|
|
274
|
+
? h('span', { class: 'fx-pop__pin fx-pop__pin--fixed', role: 'img', 'aria-label': trail.label }, mark)
|
|
275
|
+
: h('button', {
|
|
276
|
+
type: 'button', class: 'fx-pop__pin', 'aria-label': trail.label, 'aria-pressed': trail.pressed ? 'true' : 'false',
|
|
277
|
+
onclick: (event) => { event.stopPropagation(); trail.run(); },
|
|
278
|
+
}, mark);
|
|
279
|
+
return h('div', { class: 'fx-pop__row', role: 'none' }, item, control);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function placePanel() {
|
|
230
283
|
const at = button.getBoundingClientRect();
|
|
284
|
+
const margin = 8;
|
|
285
|
+
const narrow = place === 'side' && window.matchMedia('(max-width: 820px)').matches;
|
|
286
|
+
if (narrow) {
|
|
287
|
+
// On a phone the sidebar is the slide-out. The menu stays inside it.
|
|
288
|
+
const drawer = button.closest('.fx-nav')?.getBoundingClientRect();
|
|
289
|
+
const left = Math.round((drawer?.left ?? at.left) + margin);
|
|
290
|
+
const width = Math.max(160, Math.round((drawer?.width ?? at.width) - margin * 2));
|
|
291
|
+
panel.style.width = `${width}px`;
|
|
292
|
+
panel.style.minWidth = '0';
|
|
293
|
+
let top = Math.round(at.bottom + 6);
|
|
294
|
+
if (top + panel.offsetHeight > innerHeight - margin) top = Math.max(margin, Math.round(innerHeight - margin - panel.offsetHeight));
|
|
295
|
+
panel.style.top = `${top}px`;
|
|
296
|
+
panel.style.left = `${left}px`;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
panel.style.width = '';
|
|
300
|
+
panel.style.minWidth = '';
|
|
301
|
+
if (place === 'side') {
|
|
302
|
+
const gap = 8;
|
|
303
|
+
let left = Math.round(at.right + gap);
|
|
304
|
+
if (left + panel.offsetWidth > innerWidth - margin) left = Math.round(at.left - gap - panel.offsetWidth);
|
|
305
|
+
panel.style.left = `${Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin))}px`;
|
|
306
|
+
let top = Math.round(at.top);
|
|
307
|
+
panel.style.top = `${Math.max(margin, Math.min(top, innerHeight - panel.offsetHeight - margin))}px`;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
231
310
|
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
232
311
|
const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
|
|
233
|
-
panel.style.left = `${Math.round(Math.max(
|
|
312
|
+
panel.style.left = `${Math.round(Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin)))}px`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function draw(keepFocus) {
|
|
316
|
+
const active = document.activeElement;
|
|
317
|
+
const row = active?.closest?.('.fx-pop__row');
|
|
318
|
+
const text = row?.querySelector('[role="menuitem"]')?.textContent?.trim() ?? '';
|
|
319
|
+
const onPin = Boolean(active?.classList?.contains('fx-pop__pin'));
|
|
320
|
+
panel.replaceChildren(...entries().map(renderEntry));
|
|
321
|
+
if (!keepFocus || !text) return;
|
|
322
|
+
const next = [...panel.querySelectorAll('.fx-pop__row')].find((candidate) => candidate.querySelector('[role="menuitem"]')?.textContent?.trim() === text);
|
|
323
|
+
(onPin ? next?.querySelector('.fx-pop__pin') : next?.querySelector('[role="menuitem"]'))?.focus();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
panel.refresh = () => {
|
|
327
|
+
if (!panel.matches(':popover-open')) return;
|
|
328
|
+
draw(true);
|
|
329
|
+
placePanel();
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
panel.addEventListener('toggle', (event) => {
|
|
333
|
+
const open = event.newState === 'open';
|
|
334
|
+
button.setAttribute('aria-expanded', String(open));
|
|
335
|
+
if (!open) {
|
|
336
|
+
// A chosen row moves focus itself. Anything else (Escape, a click
|
|
337
|
+
// outside) puts focus back on the button that opened the menu.
|
|
338
|
+
if (place === 'side' && !picking) button.focus();
|
|
339
|
+
picking = false;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
draw(false);
|
|
343
|
+
placePanel();
|
|
344
|
+
if (place === 'side') panel.querySelector('[role="menuitem"]')?.focus();
|
|
234
345
|
});
|
|
346
|
+
|
|
347
|
+
if (place === 'side') {
|
|
348
|
+
panel.addEventListener('keydown', (event) => {
|
|
349
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
|
350
|
+
const stops = [...panel.querySelectorAll('button')];
|
|
351
|
+
if (!stops.length) return;
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
const index = Math.max(0, stops.indexOf(document.activeElement));
|
|
354
|
+
const next = event.key === 'Home' ? 0
|
|
355
|
+
: event.key === 'End' ? stops.length - 1
|
|
356
|
+
: event.key === 'ArrowDown' ? (index + 1) % stops.length
|
|
357
|
+
: (index - 1 + stops.length) % stops.length;
|
|
358
|
+
stops[next].focus();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
235
362
|
return [button, panel];
|
|
236
363
|
}
|
|
237
364
|
|
|
@@ -255,31 +382,59 @@ export function tileCard({ href, mark, title, status, blurb, meta, action = 'Man
|
|
|
255
382
|
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
|
|
256
383
|
const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
|
|
257
384
|
if (row) {
|
|
258
|
-
return h('a', { class: 'fx-tile', href, onclick: inPlace },
|
|
385
|
+
return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
|
|
259
386
|
logo,
|
|
260
387
|
h('h3', {}, title),
|
|
261
|
-
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
|
|
388
|
+
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null)), 'tileCard');
|
|
262
389
|
}
|
|
263
|
-
return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
|
|
390
|
+
return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
|
|
264
391
|
h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
|
|
265
392
|
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
266
|
-
h('span', { class: 'fx-int-card__btn' }, action));
|
|
393
|
+
h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
|
|
267
394
|
}
|
|
268
395
|
|
|
269
396
|
// A titled group of one-line cards, three columns where they fit, with "Show all" at the right
|
|
270
397
|
// when `href` is given (Claude's Customize page).
|
|
271
398
|
export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
272
|
-
return h('section', { class: 'fx-tiles' },
|
|
399
|
+
return applyPhoneRule(h('section', { class: 'fx-tiles' },
|
|
273
400
|
h('header', { class: 'fx-tiles__head' },
|
|
274
401
|
h('h2', {}, title),
|
|
275
402
|
href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
|
|
276
|
-
h('div', { class: 'fx-tiles__grid' }, cards));
|
|
403
|
+
h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// A dialog that fills the screen has no pixel outside it, so an accessibility
|
|
407
|
+
// check cannot tell it is modal and reads the page underneath. While one is
|
|
408
|
+
// open, every other top-level node is inert. The attribute is what the check
|
|
409
|
+
// reads; the browser's own modal behaviour is unchanged.
|
|
410
|
+
function shieldBehindDialogs() {
|
|
411
|
+
const top = [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
|
|
412
|
+
for (const child of document.body.children) {
|
|
413
|
+
if (top && child !== top) child.setAttribute('inert', '');
|
|
414
|
+
else child.removeAttribute('inert');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Node tests import this module and have no HTMLDialogElement. The wrap only
|
|
418
|
+
// matters in the browser, where showModal and close are the three call sites.
|
|
419
|
+
if (typeof HTMLDialogElement !== 'undefined') {
|
|
420
|
+
const openDialog = HTMLDialogElement.prototype.showModal;
|
|
421
|
+
const closeDialog = HTMLDialogElement.prototype.close;
|
|
422
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
423
|
+
const result = openDialog.call(this);
|
|
424
|
+
shieldBehindDialogs();
|
|
425
|
+
return result;
|
|
426
|
+
};
|
|
427
|
+
HTMLDialogElement.prototype.close = function close(...args) {
|
|
428
|
+
const result = closeDialog.apply(this, args);
|
|
429
|
+
shieldBehindDialogs();
|
|
430
|
+
return result;
|
|
431
|
+
};
|
|
277
432
|
}
|
|
278
433
|
|
|
279
434
|
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
|
280
435
|
export function crumbs(...trail) {
|
|
281
436
|
const here = trail.pop();
|
|
282
|
-
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
437
|
+
return applyPhoneRule(h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
283
438
|
...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
|
|
284
|
-
h('span', {}, here));
|
|
439
|
+
h('span', {}, here)), 'crumbs');
|
|
285
440
|
}
|