fix(folio-web): drop aria-label data-carrier abuse

Account cards now expose data-account-id + data-balance attrs and use a
human-readable aria-label. total-balance / ledger / ledger-balance carry
data-cents and data-txn-count instead of stuffing values into title.
Spec rewritten to read structured attrs via object-form selectors.
This commit is contained in:
pj committed 2026-04-27 18:55:42 +07:00
1 parent 8e9ac938f3
commit 0dda618187
3 files changed
+65 -74

No files matched your search

+62 -68
View File
@@ -15,74 +15,73 @@ import {
} from "@sanderling/spec"; } from "@sanderling/spec";
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties"; import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
// Route detection via stable DOM ids // Page-presence checks via stable element ids.
const onLoginPage = extract((s) => !!s.ax.find("id:email")); const onLoginPage = extract((s) => !!s.ax.find({ id: "email" }));
const onHomePage = extract((s) => !!s.ax.find("id:add-account")); const onHomePage = extract((s) => !!s.ax.find({ id: "add-account" }));
const onAddAccountPage = extract((s) => !!s.ax.find("id:account-name")); const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" }));
const onLedgerPage = extract((s) => !!s.ax.find("id:ledger")); const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" }));
const onAddTxnPage = extract((s) => !!s.ax.find("id:txn-amount")); const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" }));
// Auth state: true on any authenticated page, false only on login page // Auth state: true on any authenticated page, false only on login page.
const loggedIn = extract((s) => { const loggedIn = extract((s) => {
if (s.ax.find("id:email")) return false; if (s.ax.find({ id: "email" })) return false;
return !!( return !!(
s.ax.find("id:logout") || s.ax.find({ id: "logout" }) ||
s.ax.find("id:add-account") || s.ax.find({ id: "add-account" }) ||
s.ax.find("id:ledger") || s.ax.find({ id: "ledger" }) ||
s.ax.find("id:account-name") || s.ax.find({ id: "account-name" }) ||
s.ax.find("id:txn-amount") || s.ax.find({ id: "txn-amount" }) ||
s.ax.find("id:add-txn") s.ax.find({ id: "add-txn" })
); );
}); });
// Total balance from title attribute on id:total-balance (set to raw cents integer) // Read raw cents off explicit data-cents attributes; no aria-label parsing.
function readCents(value: string | undefined): number {
if (!value) return 0;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? 0 : parsed;
}
const totalBalance = extract((s) => { const totalBalance = extract((s) => {
const el = s.ax.find("id:total-balance"); const el = s.ax.find({ id: "total-balance" });
if (!el) return 0; return readCents(el?.attrs?.["data-cents"]);
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
}); });
// Account cards expose "account:{id}:{balance}" via aria-label -> content-desc // Account cards expose `data-account-id` + `data-balance` so the spec reads
// structured data without parsing aria-label.
const accountCards = extract((s) => { const accountCards = extract((s) => {
return s.ax.findAll("descPrefix:account:").map((el) => { return s.ax.findAll({ "data-testid": "account-card" }).map((el) => ({
const parts = ((el as { desc?: string }).desc ?? "").split(":"); element: el,
const balance = parseInt(parts[2] ?? "0", 10); id: el.attrs?.["data-account-id"] ?? "",
return { element: el, id: parts[1] ?? "", balance: isNaN(balance) ? 0 : balance }; balance: readCents(el.attrs?.["data-balance"]),
}); }));
}); });
// Ledger txn count from title on id:ledger
const ledgerTxnCount = extract((s) => { const ledgerTxnCount = extract((s) => {
const el = s.ax.find("id:ledger"); const el = s.ax.find({ id: "ledger" });
if (!el) return 0; return readCents(el?.attrs?.["data-txn-count"]);
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
}); });
// Ledger balance from title on id:ledger-balance
const ledgerBalance = extract((s) => { const ledgerBalance = extract((s) => {
const el = s.ax.find("id:ledger-balance"); const el = s.ax.find({ id: "ledger-balance" });
if (!el) return 0; return readCents(el?.attrs?.["data-cents"]);
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
}); });
// UI element handles // UI element handles.
const emailField = extract((s) => s.ax.find("id:email")); const emailField = extract((s) => s.ax.find({ id: "email" }));
const passwordField = extract((s) => s.ax.find("id:password")); const passwordField = extract((s) => s.ax.find({ id: "password" }));
const loginSubmit = extract((s) => s.ax.find("id:login-submit")); const loginSubmit = extract((s) => s.ax.find({ id: "login-submit" }));
const logoutButton = extract((s) => s.ax.find("id:logout")); const logoutButton = extract((s) => s.ax.find({ id: "logout" }));
const addAccountButton = extract((s) => s.ax.find("id:add-account")); const addAccountButton = extract((s) => s.ax.find({ id: "add-account" }));
const accountNameField = extract((s) => s.ax.find("id:account-name")); const accountNameField = extract((s) => s.ax.find({ id: "account-name" }));
const addAccountSubmit = extract((s) => s.ax.find("id:add-account-submit")); const addAccountSubmit = extract((s) => s.ax.find({ id: "add-account-submit" }));
const addTxnButton = extract((s) => s.ax.find("id:add-txn")); const addTxnButton = extract((s) => s.ax.find({ id: "add-txn" }));
const txnAmountField = extract((s) => s.ax.find("id:txn-amount")); const txnAmountField = extract((s) => s.ax.find({ id: "txn-amount" }));
const txnNoteField = extract((s) => s.ax.find("id:txn-note")); const txnNoteField = extract((s) => s.ax.find({ id: "txn-note" }));
const txnCreditButton = extract((s) => s.ax.find("id:txn-credit")); const txnCreditButton = extract((s) => s.ax.find({ id: "txn-credit" }));
const txnDebitButton = extract((s) => s.ax.find("id:txn-debit")); const txnDebitButton = extract((s) => s.ax.find({ id: "txn-debit" }));
const txnSubmit = extract((s) => s.ax.find("id:txn-submit")); const txnSubmit = extract((s) => s.ax.find({ id: "txn-submit" }));
const backButton = extract((s) => s.ax.find("id:back")); const backButton = extract((s) => s.ax.find({ id: "back" }));
// -- Properties -- // -- Properties --
@@ -98,8 +97,6 @@ const loggedOutReachesLogin = always(
), ),
); );
// Total balance must equal the sum of all account balances visible on home page.
// Only checked when home page is active and account cards are loaded.
const totalBalanceMatchesAccounts = always(() => { const totalBalanceMatchesAccounts = always(() => {
if (!onHomePage.current) return true; if (!onHomePage.current) return true;
const cards = accountCards.current; const cards = accountCards.current;
@@ -108,8 +105,6 @@ const totalBalanceMatchesAccounts = always(() => {
return sum === totalBalance.current; return sum === totalBalance.current;
}); });
// When a transaction is added (ledger txn count increases by 1 on the same
// account), the ledger balance delta matches what was actually stored.
const balanceMatchesTransactionDelta = always( const balanceMatchesTransactionDelta = always(
now(() => onLedgerPage.current && ledgerTxnCount.current > 0).implies( now(() => onLedgerPage.current && ledgerTxnCount.current > 0).implies(
next(() => { next(() => {
@@ -119,7 +114,6 @@ const balanceMatchesTransactionDelta = always(
if (curCount !== prevCount + 1) return true; if (curCount !== prevCount + 1) return true;
const prevBal = ledgerBalance.previous ?? 0; const prevBal = ledgerBalance.previous ?? 0;
const curBal = ledgerBalance.current; const curBal = ledgerBalance.current;
// Delta must be non-zero (a valid transaction was added)
return curBal !== prevBal; return curBal !== prevBal;
}), }),
), ),
@@ -149,19 +143,7 @@ export const properties = {
const DEMO_EMAIL = "[email protected]"; const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123"; const DEMO_PASSWORD = "ledger123";
const loginHelper = actions(() => { function focusedField(): string | null {
if (loggedIn.current) return [];
const email = emailField.current;
const password = passwordField.current;
const submit = loginSubmit.current;
if (!email || !password || !submit) return [];
const focused = s_focused();
if (focused === "password") return [Tap({ on: submit })];
if (focused === "email") return [InputText({ into: password, text: DEMO_PASSWORD })];
return [InputText({ into: email, text: DEMO_EMAIL })];
});
function s_focused(): string | null {
const email = emailField.current; const email = emailField.current;
const password = passwordField.current; const password = passwordField.current;
if (email && (email as { focused?: boolean }).focused) return "email"; if (email && (email as { focused?: boolean }).focused) return "email";
@@ -169,6 +151,18 @@ function s_focused(): string | null {
return null; return null;
} }
const loginHelper = actions(() => {
if (loggedIn.current) return [];
const email = emailField.current;
const password = passwordField.current;
const submit = loginSubmit.current;
if (!email || !password || !submit) return [];
const focused = focusedField();
if (focused === "password") return [Tap({ on: submit })];
if (focused === "email") return [InputText({ into: password, text: DEMO_PASSWORD })];
return [InputText({ into: email, text: DEMO_EMAIL })];
});
const adversarialLogin = actions(() => { const adversarialLogin = actions(() => {
if (loggedIn.current) return []; if (loggedIn.current) return [];
const submit = loginSubmit.current; const submit = loginSubmit.current;
+2 -3
View File
@@ -86,8 +86,7 @@ export function HomePage(props: {
<div <div
id="total-balance" id="total-balance"
className="balance-display" className="balance-display"
data-value={totalBalance} data-cents={totalBalance}
title={String(totalBalance)}
> >
<span className="balance-label">Total balance</span> <span className="balance-label">Total balance</span>
<span <span
@@ -164,7 +163,7 @@ export function HomePage(props: {
data-name={a.name} data-name={a.name}
data-balance={bal} data-balance={bal}
data-txn-count={countTxns(txns, a.id)} data-txn-count={countTxns(txns, a.id)}
aria-label={'account:' + a.id + ':' + bal} aria-label={`${a.name}, ${formatCents(bal)}`}
onClick={() => navigate(`/accounts/${a.id}`)} onClick={() => navigate(`/accounts/${a.id}`)}
> >
<span className="account-avatar" aria-hidden="true"> <span className="account-avatar" aria-hidden="true">
+1 -3
View File
@@ -93,13 +93,11 @@ export function LedgerPage(props: {
data-account-id={props.accountId} data-account-id={props.accountId}
data-account-name={account?.name ?? ''} data-account-name={account?.name ?? ''}
data-txn-count={txns?.length ?? 0} data-txn-count={txns?.length ?? 0}
title={String(txns?.length ?? 0)}
> >
<div <div
id="ledger-balance" id="ledger-balance"
className="balance-display" className="balance-display"
data-value={balance} data-cents={balance}
title={String(balance)}
> >
<span className="balance-label">Balance</span> <span className="balance-label">Balance</span>
<span <span