From fd460bdc892fe748413a4d32b69d625dd616bd87 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 4 Nov 2026 07:16:31 -0800 Subject: [PATCH] refactor(web): share Bits UI dialogs and fix Woodpecker E2E image --- .changeset/share-register-detail-dialogs.md | 9 ++ .woodpecker/web.yml | 2 +- README.md | 2 +- apps/web/Dockerfile | 2 +- apps/web/README.md | 12 ++ apps/web/e2e/cash-register.e2e.ts | 15 ++- apps/web/e2e/investment-register.e2e.ts | 11 +- apps/web/package.json | 1 + apps/web/src/lib/DetailDialog.svelte | 123 ++++++++++++++++++ .../lib/registers/cash/CashRegister.svelte | 17 ++- .../cash/TransactionBreakdown.svelte | 71 ++-------- .../investment/InvestmentDetails.svelte | 77 ++--------- .../investment/InvestmentRegister.svelte | 9 +- docs/architecture.md | 6 + pnpm-lock.yaml | 122 ++++++++++++++++- 15 files changed, 341 insertions(+), 138 deletions(-) create mode 100644 .changeset/share-register-detail-dialogs.md create mode 100644 apps/web/src/lib/DetailDialog.svelte diff --git a/.changeset/share-register-detail-dialogs.md b/.changeset/share-register-detail-dialogs.md new file mode 100644 index 0000000..2910b9c --- /dev/null +++ b/.changeset/share-register-detail-dialogs.md @@ -0,0 +1,9 @@ +--- +'@hledger-reports/web': patch +--- + +Use a small shared Bits UI shell for Cash transaction breakdowns and Investment details. Keep native financial content and payload-bound selection in their features while the shell handles the portal, focus trapping, scroll lock, close control and return focus. + +Preserve Escape/close-button dismissal and non-dismissive backdrop clicks, clear selections on navigation or changed page data, and retain readable report tables without JavaScript. No new financial transformations or unrelated UI controls are introduced. + +Use the private `git.eleith.com/eleith.private/playwright` image for Woodpecker E2E runs, matching the approved reference pipelines and the pinned Playwright version. diff --git a/.woodpecker/web.yml b/.woodpecker/web.yml index 48b4d75..e2f2402 100644 --- a/.woodpecker/web.yml +++ b/.woodpecker/web.yml @@ -82,7 +82,7 @@ steps: - pnpm --filter @hledger-reports/worker build e2e: - image: ghcr.io/eleith/containers-playwright:1.61.1-node26 + image: git.eleith.com/eleith.private/playwright:1.61.1-node26 commands: - pnpm config set store-dir .pnpm-store - export PATH="$PWD/.cache/ci-bin:$PATH" diff --git a/README.md b/README.md index 17bc8ce..7e6706d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The hledger library reads the journal and organizes neutral, table/chart-ready ` **`/queries` remains a separate live report:** each Run executes native balance at recorded cost against the journal current at execution. Exact results persist in SQLite as operational workflow output, but the page displays only its latest response; reload clears it. Neither page offers user-facing run history. -Sign-in is required, but this remains loopback-only development software. Do not expose it publicly or through a reverse proxy. See [shared access and authentication](docs/authentication.md). General report catalogs and a transaction-detail UI are not implemented. +Sign-in is required, but this remains loopback-only development software. Do not expose it publicly or through a reverse proxy. See [shared access and authentication](docs/authentication.md). Curated report catalogs and search remain deferred. ## Setup and run diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 57f409a..fc78b58 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -26,7 +26,7 @@ RUN pnpm --filter "@hledger-reports/web..." build FROM builder AS pruner RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm --config.hoist-workspace-packages=false --filter @hledger-reports/web --prod deploy --legacy /pruned -# Match When's browser image and the @playwright/test version together. +# Keep the browser image and @playwright/test versions aligned. FROM ghcr.io/eleith/containers-playwright:1.61.1-node26 AS e2e WORKDIR /m ENV CI=true diff --git a/apps/web/README.md b/apps/web/README.md index 6019c36..053b113 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -69,6 +69,18 @@ labels, split flags, changes and attached running balances survive pagination. Empty accounts have no invented transaction; ambiguous native selectors show no account amounts. First/Previous/Next/Last are ordinary links, including without JS. +## Transaction dialogs + +Cash breakdowns and Investment details share a small Bits UI shell. Their financial +content and selection stay in the owning feature. Escape and the close button dismiss; +backdrop clicks do not. Closing restores focus to the opener when available, otherwise +to main content. Navigation or replacement page data invalidates the selected record. + +The portal inherits document theme/typography and keeps the desktop dialog and narrow +bottom-sheet layout. Bits UI supplies focus trapping and body scroll lock. Detail +buttons remain disabled before hydration; the underlying financial tables work +without JavaScript. There are no forced SSR dialog contents or unrelated Bits controls. + ## Files and state Compose shares ignored **`apps/web/data/`** at `/app/data` in both processes; no diff --git a/apps/web/e2e/cash-register.e2e.ts b/apps/web/e2e/cash-register.e2e.ts index 79a879b..4892f7e 100644 --- a/apps/web/e2e/cash-register.e2e.ts +++ b/apps/web/e2e/cash-register.e2e.ts @@ -15,12 +15,23 @@ test('Cash register opens saved activity and a transaction breakdown', async ({ '8520.00 USD' ); - await activity.getByRole('button', { name: 'Breakdown', exact: true }).first().click(); + const opener = activity.getByRole('button', { name: 'Breakdown', exact: true }).first(); + await opener.click(); const dialog = page.getByRole('dialog', { name: 'Transaction breakdown', exact: true }); await expect(dialog).toBeVisible(); const splits = dialog.getByRole('table', { name: 'Split details', exact: true }); await expect(splits).toContainText('Brokerage'); await expect(splits.getByRole('cell', { name: '10.00 VTI', exact: true })).toBeVisible(); - await dialog.getByRole('button', { name: 'Close breakdown', exact: true }).click(); + const close = dialog.getByRole('button', { name: 'Close breakdown', exact: true }); + await expect(close).toBeFocused(); + await page.mouse.click(1, 1); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); + await expect(opener).toBeFocused(); + await opener.press('Enter'); + await expect(dialog).toBeVisible(); + await close.click(); + await expect(dialog).not.toBeVisible(); + await expect(opener).toBeFocused(); }); diff --git a/apps/web/e2e/investment-register.e2e.ts b/apps/web/e2e/investment-register.e2e.ts index 59118f2..1662ceb 100644 --- a/apps/web/e2e/investment-register.e2e.ts +++ b/apps/web/e2e/investment-register.e2e.ts @@ -20,7 +20,8 @@ test('Investment register shows cash once per transaction and opens details', as await expect(purchase.locator('[data-column="cash-balance"]')).toHaveCount(1); await expect(groups.last().locator('[data-column="cash-balance"]')).toHaveText('200.00 USD'); - await purchase.getByRole('button', { name: 'Details', exact: true }).click(); + const opener = purchase.getByRole('button', { name: 'Details', exact: true }); + await opener.click(); const details = page.getByRole('dialog', { name: 'Investment details', exact: true }); await expect(details).toBeVisible(); await expect(details.getByRole('table', { name: 'Related activity', exact: true })).toContainText( @@ -28,4 +29,12 @@ test('Investment register shows cash once per transaction and opens details', as ); await details.getByRole('button', { name: 'Close details', exact: true }).click(); await expect(details).not.toBeVisible(); + await expect(opener).toBeFocused(); + await opener.click(); + await expect(details).toBeVisible(); + await page.goBack(); + await expect( + page.getByRole('heading', { name: 'Investment register', exact: true }) + ).toBeVisible(); + await expect(details).not.toBeVisible(); }); diff --git a/apps/web/package.json b/apps/web/package.json index 12b7646..d5965e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@hledger-reports/jobs": "workspace:*", "@hledger-reports/snapshots": "workspace:*", "better-auth": "1.7.14", + "bits-ui": "2.18.1", "chart.js": "4.5.1" }, "devDependencies": { diff --git a/apps/web/src/lib/DetailDialog.svelte b/apps/web/src/lib/DetailDialog.svelte new file mode 100644 index 0000000..8d8a579 --- /dev/null +++ b/apps/web/src/lib/DetailDialog.svelte @@ -0,0 +1,123 @@ + + + open, + (next) => { + if (!next) onClose(); + } + } +> + + + {#snippet child({ props })} +
+ {/snippet} +
+ + {#snippet child({ props })} +
+
+ + {#snippet child({ props: titleProps })} +

{title}

+ {/snippet} +
+ + {#snippet child({ props: closeProps })} + + {/snippet} + +
+ {@render children()} +
+ {/snippet} +
+
+
+ + diff --git a/apps/web/src/lib/registers/cash/CashRegister.svelte b/apps/web/src/lib/registers/cash/CashRegister.svelte index e6c5228..8a0c1d4 100644 --- a/apps/web/src/lib/registers/cash/CashRegister.svelte +++ b/apps/web/src/lib/registers/cash/CashRegister.svelte @@ -10,6 +10,7 @@ let { data, labels = [] }: { data: CashRegisterData; labels?: readonly AccountLabel[] } = $props(); let hydrated = $state(false); + let opener = $state(null); let selection = $state.raw<{ context: CashRegisterData; record: CashBreakdown } | null>(null); // Invalidate synchronously with a replacement payload, before rendering its rows. let selectedRecord = $derived(selection?.context === data ? selection.record : null); @@ -18,6 +19,10 @@ onMount(() => { hydrated = true; }); + function openBreakdown(record: CashBreakdown, button: HTMLButtonElement) { + opener = button; + selection = { context: data, record }; + } function closeBreakdown() { selection = null; } @@ -113,8 +118,9 @@ class="ui-button ui-button--compact" type="button" disabled={!hydrated} - onclick={() => { - if (row.breakdown) selection = { context: data, record: row.breakdown }; + aria-haspopup="dialog" + onclick={(event) => { + if (row.breakdown) openBreakdown(row.breakdown, event.currentTarget); }}>Breakdown{/if} @@ -159,7 +165,12 @@ {/if} - + diff --git a/apps/web/src/lib/registers/investment/InvestmentDetails.svelte b/apps/web/src/lib/registers/investment/InvestmentDetails.svelte index 7a8e41a..a3df88a 100644 --- a/apps/web/src/lib/registers/investment/InvestmentDetails.svelte +++ b/apps/web/src/lib/registers/investment/InvestmentDetails.svelte @@ -1,5 +1,6 @@ {#snippet amountList(amounts: ReportedAmount[] | null)} @@ -53,22 +41,16 @@ {/if} {/snippet} - + {#if selectedTransaction} {@const cash = selectedTransaction.cash} -
-

{selectedTransaction.payee || 'Investment details'}

- - -

{selectedTransaction.date}

{#if selectedTransaction.notes.some((note) => note.trim().length > 0)}
@@ -189,35 +171,9 @@ {/if} {/if} -
+ diff --git a/apps/web/src/lib/registers/investment/InvestmentRegister.svelte b/apps/web/src/lib/registers/investment/InvestmentRegister.svelte index bc63a7a..ea823f6 100644 --- a/apps/web/src/lib/registers/investment/InvestmentRegister.svelte +++ b/apps/web/src/lib/registers/investment/InvestmentRegister.svelte @@ -11,6 +11,7 @@ let { data, labels = [] }: { data: InvestmentRegisterData; labels?: readonly AccountLabel[] } = $props(); let hydrated = $state(false); + let opener = $state(null); let selection = $state.raw<{ pageData: InvestmentRegisterData; transaction: InvestmentTransaction; @@ -22,7 +23,8 @@ hydrated = true; }); - function openDetails(transaction: InvestmentTransaction) { + function openDetails(transaction: InvestmentTransaction, button: HTMLButtonElement) { + opener = button; selection = { pageData: data, transaction }; } @@ -205,7 +207,8 @@ class="ui-button ui-button--compact" type="button" disabled={!hydrated} - onclick={() => openDetails(transaction)} + aria-haspopup="dialog" + onclick={(event) => openDetails(transaction, event.currentTarget)} > Details @@ -268,7 +271,7 @@ {/if} - +