diff --git a/e2e/entry-copy.test.ts b/e2e/entry-copy.test.ts
new file mode 100644
index 0000000..bcde9c1
--- /dev/null
+++ b/e2e/entry-copy.test.ts
@@ -0,0 +1,84 @@
+/** Real-browser coverage for the entry table's controls (issue #67). jsdom
+ * dispatches events straight at an element; only a real browser hit-tests a
+ * coordinate, so this is what proves the cells and the open control are
+ * actually clickable where they render.
+ *
+ * What gets copied is asserted in the jsdom tests instead: headless Chrome
+ * refuses `navigator.clipboard.writeText` outright ("Write permission denied"),
+ * so no toast ever appears here. */
+import assert from 'node:assert/strict';
+import { after, before, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import puppeteer, { type Browser, type Frame, type Page } from 'puppeteer-core';
+import { openApp } from './support/app.ts';
+import { resolveChromePath } from './support/chrome.ts';
+import { type DistServer, startDistServer } from './support/dist-server.ts';
+import { type KdbxFixture, writeKdbxFixture } from './support/fixture.ts';
+import { resolveLaunchOptions } from './support/launch-options.ts';
+
+const distDir = fileURLToPath(new URL('../dist', import.meta.url));
+
+let server: DistServer;
+let browser: Browser;
+let page: Page;
+let app: Frame;
+let fixture: KdbxFixture;
+
+before(async () => {
+ server = await startDistServer(distDir);
+ browser = await puppeteer.launch({
+ executablePath: resolveChromePath(),
+ ...resolveLaunchOptions(),
+ args: ['--no-sandbox'],
+ });
+ page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 900 });
+ fixture = await writeKdbxFixture();
+ app = await openApp(page, server.origin, fixture);
+ await app.click('[data-action="view-table"]');
+ await app.waitForSelector('.entry-table');
+});
+
+after(async () => {
+ await browser.close();
+ await server.close();
+});
+
+/** Centre of the cell holding the fixture entry's username. */
+async function usernameCellCentre(): Promise<{ x: number; y: number }> {
+ const box = await app.$$eval(
+ '.entry-table tbody td',
+ (cells, name) => {
+ const cell = cells.find((c) => c.firstChild?.textContent === name);
+ if (!cell) return null;
+ const rect = cell.getBoundingClientRect();
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ },
+ 'octocat',
+ );
+ assert.ok(box, 'the username cell is on screen');
+ const frameBox = await (await app.frameElement())?.boundingBox();
+ assert.ok(frameBox, 'the app frame is laid out');
+ return { x: frameBox.x + box.x, y: frameBox.y + box.y };
+}
+
+test('clicking a value never opens the entry', async () => {
+ const { x, y } = await usernameCellCentre();
+ await page.mouse.click(x, y);
+ // Opening is synchronous now, so there is nothing to wait out.
+ assert.equal(await app.$('#detail-title'), null, 'the card stayed shut');
+});
+
+test('the row control is the way into the card', async () => {
+ const openButton = await app.$('.entry-table-open button');
+ assert.ok(openButton, 'every row carries one');
+ await openButton.click();
+
+ const title = await app.waitForSelector('#detail-title');
+ assert.ok(title, 'the card opened');
+ assert.match(
+ (await title.evaluate((el) => el.textContent)) ?? '',
+ /Example Entry/,
+ 'and it is the row that was clicked',
+ );
+});
diff --git a/e2e/group-rail.test.ts b/e2e/group-rail.test.ts
index b5fee3e..ff04303 100644
--- a/e2e/group-rail.test.ts
+++ b/e2e/group-rail.test.ts
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Frame, type Page } from 'puppeteer-core';
+import { openApp } from './support/app.ts';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { type KdbxFixture, writeKdbxFixture } from './support/fixture.ts';
@@ -32,28 +33,9 @@ before(async () => {
await page.setViewport({ width: 1280, height: 900 });
fixture = await writeKdbxFixture();
- app = await openApp(page);
+ app = await openApp(page, server.origin, fixture);
});
-/** Upload the fixture to local.html and unlock the app it embeds, returning
- * the app's frame. */
-async function openApp(target: Page): Promise {
- await target.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
- const fileInput = (await target.waitForSelector(
- '#file-input',
- )) as ElementHandle;
- await fileInput.uploadFile(fixture.path);
- const frameElement = await target.waitForSelector('#app-frame');
- assert.ok(frameElement, 'the app is embedded in an iframe');
- const frame = (await frameElement.contentFrame()) as Frame;
- const passwordInput = await frame.waitForSelector('#master-password');
- assert.ok(passwordInput, 'the embedded app shows its unlock screen');
- await passwordInput.type(fixture.password);
- await frame.click('#unlock-btn');
- await frame.waitForSelector('#group-tree .group-btn');
- return frame;
-}
-
after(async () => {
await browser.close();
await server.close();
@@ -119,7 +101,7 @@ test('dragging the handle resizes the rail', async () => {
test('at phone width the rail is a drawer: no resize handle, and ⋯ still reaches rename', async () => {
const phone = await browser.newPage();
await phone.setViewport({ width: 375, height: 812 });
- const phoneApp = await openApp(phone);
+ const phoneApp = await openApp(phone, server.origin, fixture);
assert.equal(
await phoneApp.$eval('#sidebar-resize', (el) => getComputedStyle(el).display),
diff --git a/e2e/support/app.ts b/e2e/support/app.ts
new file mode 100644
index 0000000..dcae302
--- /dev/null
+++ b/e2e/support/app.ts
@@ -0,0 +1,20 @@
+/** Uploads the fixture to local.html and unlocks the app it embeds, returning
+ * the app's frame. Shared by every e2e that needs an open database. */
+import assert from 'node:assert/strict';
+import type { ElementHandle, Frame, Page } from 'puppeteer-core';
+import type { KdbxFixture } from './fixture.ts';
+
+export async function openApp(page: Page, origin: string, fixture: KdbxFixture): Promise {
+ await page.goto(`${origin}/local.html`, { waitUntil: 'networkidle0' });
+ const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle;
+ await fileInput.uploadFile(fixture.path);
+ const frameElement = await page.waitForSelector('#app-frame');
+ assert.ok(frameElement, 'the app is embedded in an iframe');
+ const frame = (await frameElement.contentFrame()) as Frame;
+ const passwordInput = await frame.waitForSelector('#master-password');
+ assert.ok(passwordInput, 'the embedded app shows its unlock screen');
+ await passwordInput.type(fixture.password);
+ await frame.click('#unlock-btn');
+ await frame.waitForSelector('#group-tree .group-btn');
+ return frame;
+}
diff --git a/pages/0x67/page.css b/pages/0x67/page.css
index 3fceb96..57cd3e3 100644
--- a/pages/0x67/page.css
+++ b/pages/0x67/page.css
@@ -791,7 +791,12 @@ so an absolutely positioned menu would be cut off. */
white-space: nowrap;
}
+/* A tap on a cell copies (#67), so the cell must not start a text selection or
+wait on a double-tap-to-zoom before that tap registers. */
.entry-table td {
+ touch-action: manipulation;
+ user-select: none;
+ -webkit-touch-callout: none;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
max-width: 220px;
@@ -801,6 +806,30 @@ so an absolutely positioned menu would be cut off. */
cursor: pointer;
}
+/* Present at rest, brighter on hover or focus (#67): hover may raise emphasis,
+never be what makes the affordance discoverable, since touch has none. */
+.copy-hint {
+ margin-left: 0.35rem;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 0.8em;
+ line-height: 1;
+ opacity: 0.55;
+}
+
+.entry-table td:hover .copy-hint,
+.copy-hint:focus-visible {
+ opacity: 1;
+}
+
+.entry-table-open {
+ width: 1px;
+ text-align: right;
+ white-space: nowrap;
+}
+
.entry-table tbody tr:hover {
background: var(--surface-2);
}
@@ -1464,3 +1493,32 @@ body.embedded footer {
display: none;
}
}
+
+/* Visible for exactly as long as the clipboard holds the value (#67). */
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 1.5rem;
+ transform: translateX(-50%);
+ z-index: 50;
+ max-width: min(90vw, 32rem);
+ padding: 0.5rem 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
+ color: var(--text);
+ font-size: 0.85rem;
+}
+
+/* Announced but not drawn: gives the open column a name for table navigation. */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/pages/0x67/page.html b/pages/0x67/page.html
index 4ac58e8..fa0d33f 100644
--- a/pages/0x67/page.html
+++ b/pages/0x67/page.html
@@ -360,6 +360,8 @@ Discard unsaved changes?
+
+
diff --git a/pages/0x67/page.ts b/pages/0x67/page.ts
index d42ca04..1d5d489 100644
--- a/pages/0x67/page.ts
+++ b/pages/0x67/page.ts
@@ -117,13 +117,42 @@ logic.ts) are globals from bundle-iife's concatenation; see globals.d.ts. */
let clipboardTimer: ReturnType | null = null;
-async function copyToClipboard(text: string): Promise {
+/* The most recent auto-clear (#67). Every copy waits for it before writing, so
+a clear can never resolve on top of a newer value and wipe it; a settled one
+costs nothing to await, which is why this is never null. */
+let clipboardClear: Promise = Promise.resolve();
+
+const FIELD_LABELS: Record = { UserName: 'Username' };
+
+// KeePass's own field keys are not what the rest of the app calls them (#67).
+function fieldLabel(key: string): string {
+ return FIELD_LABELS[key] ?? key;
+}
+
+/* Names the field, never the value (#67): the toast is a reminder that a secret
+is on the clipboard, so it must not put that secret on screen as well. */
+function showClipboardToast(label: string): void {
+ const toast = byId('toast');
+ toast.textContent = `${label} copied to clipboard`;
+ toast.hidden = false;
+}
+
+async function copyToClipboard(text: string, label = 'Value'): Promise {
try {
+ await clipboardClear;
await navigator.clipboard.writeText(text);
if (clipboardTimer) clearTimeout(clipboardTimer);
+ showClipboardToast(label);
+ /* One timer drives both the wipe and the toast (#67), so the toast is gone
+ exactly when the clipboard is, rather than on a schedule of its own. */
clipboardTimer = setTimeout(() => {
- navigator.clipboard.writeText('').catch(() => {});
clipboardTimer = null;
+ clipboardClear = navigator.clipboard
+ .writeText('')
+ .then(() => {
+ byId('toast').hidden = true;
+ })
+ .catch(() => {}); // the clear failed, so the value is still there and the toast stays
}, app.clipboardTimeout * 1000);
} catch (err) {
console.error('Clipboard write failed', err);
@@ -681,7 +710,7 @@ const ENTRY_COLUMNS: ReadonlyArray<{ key: EntryColumnKey; label: string }> = [
];
/** Display text: dates formatted, password masked. entryColumnValue itself
- * (unmasked) is what a double-click copies — see buildEntryTable. */
+ * (unmasked) is what a tap copies — see buildEntryTable. */
function entryColumnDisplayValue(entry: XmlElement, column: EntryColumnKey): string {
const raw = entryColumnValue(entry, column);
if (column === 'password') return raw ? '••••••••' : '';
@@ -689,31 +718,37 @@ function entryColumnDisplayValue(entry: XmlElement, column: EntryColumnKey): str
return raw;
}
-// Delays opening a row so a second click (a double-click) has time to cancel
-// it via wireCopyOnDblClick, rather than racing ahead of the copy.
-let entryRowOpenTimer: ReturnType | null = null;
-const ENTRY_ROW_OPEN_DELAY_MS = 250;
+function openEntry(entry: XmlElement): void {
+ app.currentEntry = entry;
+ showEntryDetail();
+}
-function openEntryDetailDelayed(entry: XmlElement): void {
- if (entryRowOpenTimer) clearTimeout(entryRowOpenTimer);
- entryRowOpenTimer = setTimeout(() => {
- entryRowOpenTimer = null;
- app.currentEntry = entry;
- showEntryDetail();
- }, ENTRY_ROW_OPEN_DELAY_MS);
+/* A click copies (#67); opening the card is the row's own button instead. The
+browser already decides what counts as a click, so a scroll that starts on a
+cell copies nothing and a secondary button never reaches here at all. */
+function wireCellCopy(cell: HTMLTableCellElement, value: string, label: string): void {
+ cell.addEventListener('click', (event) => {
+ if (event.target !== event.currentTarget) return; // the copy button runs its own handler
+ if (value) copyToClipboard(value, label);
+ });
}
-function wireCopyOnDblClick(cell: HTMLTableCellElement, value: string): void {
- cell.addEventListener('dblclick', (e) => {
- e.stopPropagation();
- if (entryRowOpenTimer) {
- clearTimeout(entryRowOpenTimer);
- entryRowOpenTimer = null;
- }
- if (value) copyToClipboard(value);
+/* A button, not decoration (#67): dim at rest so hover only raises its emphasis,
+and focusable so copying is reachable without a pointer at all. */
+function copyHint(value: string, label: string): HTMLButtonElement {
+ return makeIconButton('copy-hint', `Copy ${label.toLowerCase()}`, '📋', () => {
+ copyToClipboard(value, label);
});
}
+function buildEntryCell(display: string, value: string, label: string): HTMLTableCellElement {
+ const td = document.createElement('td');
+ td.appendChild(document.createTextNode(display));
+ if (value) td.appendChild(copyHint(value, label));
+ wireCellCopy(td, value, label);
+ return td;
+}
+
function buildEntryTable(rows: EntryWithGroup[]): HTMLTableElement {
const table = document.createElement('table');
table.className = 'entry-table';
@@ -730,6 +765,12 @@ function buildEntryTable(rows: EntryWithGroup[]): HTMLTableElement {
th.textContent = column.label;
headRow.appendChild(th);
}
+ const openTh = document.createElement('th');
+ const openLabel = document.createElement('span');
+ openLabel.className = 'visually-hidden';
+ openLabel.textContent = 'Open';
+ openTh.appendChild(openLabel);
+ headRow.appendChild(openTh);
thead.appendChild(headRow);
table.appendChild(thead);
@@ -737,21 +778,30 @@ function buildEntryTable(rows: EntryWithGroup[]): HTMLTableElement {
for (const { entry } of rows) {
const tr = document.createElement('tr');
- const titleTd = document.createElement('td');
+ const titleTd = buildEntryCell(
+ `${iconEmoji(elementIconId(entry))} ${entryTitle(entry)}`,
+ entryTitle(entry),
+ 'Title',
+ );
titleTd.className = 'entry-table-title';
- titleTd.textContent = `${iconEmoji(elementIconId(entry))} ${entryTitle(entry)}`;
- wireCopyOnDblClick(titleTd, entryTitle(entry));
tr.appendChild(titleTd);
for (const column of visibleColumns) {
- const td = document.createElement('td');
- td.textContent = entryColumnDisplayValue(entry, column.key);
+ const td = buildEntryCell(
+ entryColumnDisplayValue(entry, column.key),
+ entryColumnValue(entry, column.key),
+ column.label,
+ );
if (column.key === 'password') td.classList.add('entry-table-protected');
- wireCopyOnDblClick(td, entryColumnValue(entry, column.key));
tr.appendChild(td);
}
- tr.addEventListener('click', () => openEntryDetailDelayed(entry));
+ // The only way into the card, so opening to edit stays a deliberate act (#67).
+ const openTd = document.createElement('td');
+ openTd.className = 'entry-table-open';
+ openTd.appendChild(makeIconButton('icon-btn', 'Open entry', '›', () => openEntry(entry)));
+ tr.appendChild(openTd);
+
tbody.appendChild(tr);
}
table.appendChild(tbody);
@@ -1068,12 +1118,8 @@ function buildDetailField(key: string, value: string, isProtected: boolean): HTM
actions.appendChild(revealBtn);
}
- const copyBtn = makeIconButton('icon-btn', 'Copy', '📋', async () => {
- await copyToClipboard(value);
- copyBtn.textContent = '✓';
- setTimeout(() => {
- copyBtn.textContent = '📋';
- }, 1500);
+ const copyBtn = makeIconButton('icon-btn', 'Copy', '📋', () => {
+ copyToClipboard(value, fieldLabel(key));
});
actions.appendChild(copyBtn);
@@ -1298,14 +1344,10 @@ function buildEditField(
row.appendChild(toggle);
}
- const copyBtn = makeIconButton('icon-btn', 'Copy', '📋', async () => {
+ const copyBtn = makeIconButton('icon-btn', 'Copy', '📋', () => {
// Copies whatever is currently typed, not the value the field opened
// with — the user may have already edited it.
- await copyToClipboard(valueInput.value);
- copyBtn.textContent = '✓';
- setTimeout(() => {
- copyBtn.textContent = '📋';
- }, 1500);
+ copyToClipboard(valueInput.value, fieldLabel(keyInput.value) || 'Value');
});
row.appendChild(copyBtn);
diff --git a/pages/tests/0x67-page.test.ts b/pages/tests/0x67-page.test.ts
index 957c4be..3538cf5 100644
--- a/pages/tests/0x67-page.test.ts
+++ b/pages/tests/0x67-page.test.ts
@@ -1234,30 +1234,42 @@ test('0x67 app', async (t) => {
});
await t.test(
- 'copying a field writes to the clipboard, flips the icon, then reverts on a timer',
+ 'copying a field writes to the clipboard, announces it, and clears on a timer',
(t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
+ const usernameRow = Array.from(root().querySelectorAll('.detail-field')).find(
+ (row) => row.querySelector('.detail-label')?.textContent === 'UserName',
+ ) as HTMLElement;
const passwordRow = Array.from(root().querySelectorAll('.detail-field')).find(
(row) => row.querySelector('.detail-label')?.textContent === 'Password',
) as HTMLElement;
const copyBtn = passwordRow.querySelector('[title="Copy"]');
clipboardWritesShouldFail = false;
- copyBtn?.click();
- // The write and the icon flip both happen inside an async click handler;
- // let its microtasks settle before advancing fake timers.
+ usernameRow.querySelector('[title="Copy"]')?.click();
+ // The write happens inside an async handler; let its microtasks settle
+ // before advancing fake timers.
return Promise.resolve()
.then(() => Promise.resolve())
.then(() => {
- assert.equal(copyBtn?.textContent, '✓');
+ assert.equal(
+ byId('toast').textContent,
+ 'Username copied to clipboard',
+ "not KeePass's own 'UserName'",
+ );
+ copyBtn?.click();
+ return Promise.resolve().then(() => Promise.resolve());
+ })
+ .then(() => {
+ // The toast names the field and never carries the value itself (#67).
+ assert.equal(byId('toast').hidden, false);
+ assert.equal(byId('toast').textContent, 'Password copied to clipboard');
// Second, immediate copy exercises the "clear the pending timer"
// branch in copyToClipboard before advancing time at all.
copyBtn?.click();
return Promise.resolve().then(() => Promise.resolve());
})
.then(() => {
- t.mock.timers.tick(1500);
- assert.equal(copyBtn?.textContent, '📋');
// The clipboard-clear timer (app.clipboardTimeout, still the
// default 30s here) was reset by the second copy above; advance
// past it to cover the auto-clear callback itself. Force this
@@ -1274,6 +1286,19 @@ test('0x67 app', async (t) => {
// The rejected write must not throw, and must not have "succeeded"
// in clearing the (mock) clipboard either.
assert.equal(clipboardText, 'still there before the timer fires');
+ // The clear failed, so the value is still on the clipboard and the
+ // reminder must not retire as though it were gone.
+ assert.equal(byId('toast').hidden, false);
+ copyBtn?.click();
+ return Promise.resolve().then(() => Promise.resolve());
+ })
+ .then(() => {
+ t.mock.timers.tick(30_000);
+ return Promise.resolve().then(() => Promise.resolve());
+ })
+ .then(() => {
+ assert.equal(clipboardText, '', 'this clear succeeded');
+ assert.equal(byId('toast').hidden, true, 'so the reminder retires');
});
},
);
@@ -1295,15 +1320,20 @@ test('0x67 app', async (t) => {
// value the field was opened with.
valueInput.value = 'not-yet-saved-password';
clipboardWritesShouldFail = false;
+ const keyInput = passwordRow.querySelector('.edit-key') as HTMLInputElement;
+ keyInput.value = '';
copyBtn?.click();
+ keyInput.value = 'Password';
return Promise.resolve()
.then(() => Promise.resolve())
.then(() => {
assert.equal(clipboardText, 'not-yet-saved-password');
- assert.equal(copyBtn?.textContent, '✓');
- t.mock.timers.tick(1500);
- assert.equal(copyBtn?.textContent, '📋');
+ assert.equal(
+ byId('toast').textContent,
+ 'Value copied to clipboard',
+ 'an unnamed field still names something',
+ );
q('[data-action="cancel"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
});
});
@@ -2085,7 +2115,7 @@ test('entry list sorting: by title, username, or modified time, in either direct
q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
});
-test('entry list table view: default columns, masked password, column toggling, and click vs double-click', async (t) => {
+test('entry list table view: columns, masked password, click to copy, button to open', async (t) => {
const credentials = new Credentials({ password: PASSWORD, keyFile: KEYFILE });
const kdbx = await Kdbx.create(credentials, {
version: 4,
@@ -2129,12 +2159,15 @@ test('entry list table view: default columns, masked password, column toggling,
Array.from(root().querySelectorAll('.entry-table th')).map((th) => th.textContent ?? '');
assert.deepEqual(
headerText(),
- ['Title', 'Username', 'Password', 'URL', 'Modified'],
- 'default visible columns',
+ ['Title', 'Username', 'Password', 'URL', 'Modified', 'Open'],
+ "default visible columns, plus the open column's screen-reader-only name",
);
+ // The cell's own text, without the copy hint appended beside it.
const bodyCells = (): string[] =>
- Array.from(root().querySelectorAll('.entry-table tbody td')).map((td) => td.textContent ?? '');
+ Array.from(root().querySelectorAll('.entry-table tbody td')).map(
+ (td) => td.firstChild?.textContent ?? '',
+ );
const [titleCell, usernameCell, passwordCell, urlCell] = bodyCells();
assert.ok(titleCell?.includes('GitHub'));
assert.equal(usernameCell, 'octocat');
@@ -2162,29 +2195,40 @@ test('entry list table view: default columns, masked password, column toggling,
assert.ok(bodyCells().includes('work account'));
t.mock.timers.enable({ apis: ['setTimeout'] });
- const row = q('.entry-table tbody tr') as HTMLElement;
- dispatch(row, 'click');
- assert.equal(q('#detail-title'), null, 'not yet — still within the delay window');
- t.mock.timers.tick(250);
- assert.ok(q('#detail-title')?.textContent?.includes('GitHub'));
-
- // Back to the entry list, table view, to exercise the double-click path.
- dispatch(q('[data-action="back"]'), 'click');
- dispatch(q('[data-action="view-table"]'), 'click');
-
clipboardWritesShouldFail = false;
- const passwordTd = Array.from(root().querySelectorAll('.entry-table tbody td')).find(
- (td) => td.textContent === '••••••••',
- ) as HTMLElement;
- dispatch(passwordTd, 'click');
- dispatch(passwordTd, 'dblclick');
- // The double-click's own handler cancels the pending single-click timer —
- // advancing past its delay must not open the entry.
- t.mock.timers.tick(250);
- assert.equal(q('#detail-title'), null, 'the double-click cancelled the pending open');
+ const maskedCell = (): HTMLElement =>
+ Array.from(root().querySelectorAll('.entry-table tbody td')).find(
+ (td) => td.firstChild?.textContent === '••••••••',
+ ) as HTMLElement;
+
+ // A click copies the real value, names it, and leaves the card shut.
+ dispatch(maskedCell(), 'click');
await Promise.resolve();
await Promise.resolve();
assert.equal(clipboardText, 'hunter2', 'the real password was copied, not the mask');
+ assert.equal(byId('toast').hidden, false);
+ assert.equal(byId('toast').textContent, 'Password copied to clipboard');
+ assert.ok(!byId('toast').textContent?.includes('hunter2'), 'never the value itself');
+ assert.equal(q('#detail-title'), null, 'copying never opens the card');
+
+ // The 📋 is a real button, so a keyboard reaches it; its click must not also
+ // run the cell's own handler underneath.
+ clipboardText = '';
+ const hintBtn = maskedCell().querySelector('.copy-hint') as HTMLButtonElement;
+ assert.equal(hintBtn.title, 'Copy password', 'the button carries its own name');
+ dispatch(hintBtn, 'click');
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.equal(clipboardText, 'hunter2', 'the copy button copies exactly once');
+
+ // Opening the card is its own button, never a gesture over the values.
+ const openBtn = (q('.entry-table tbody tr') as HTMLElement).querySelector(
+ '.entry-table-open button',
+ ) as HTMLButtonElement;
+ dispatch(openBtn, 'click');
+ assert.ok(q('#detail-title')?.textContent?.includes('GitHub'), 'the › opens it');
+ dispatch(q('[data-action="back"]'), 'click');
+ dispatch(q('[data-action="view-table"]'), 'click');
// Turn on Created and Attachments too — Created exercises the other half
// of entryColumnDisplayValue's date-formatting check, and Attachments (0,
@@ -2208,24 +2252,12 @@ test('entry list table view: default columns, masked password, column toggling,
const attachmentsTd = (q('.entry-table tbody tr') as HTMLElement).querySelectorAll('td')[
columnIndex
] as HTMLElement;
- assert.equal(attachmentsTd.textContent, '', 'no attachments on this entry');
- dispatch(attachmentsTd, 'dblclick');
+ assert.equal(attachmentsTd.firstChild?.textContent, '', 'no attachments on this entry');
+ assert.equal(attachmentsTd.querySelector('.copy-hint'), null, 'and so no copy hint');
+ clipboardText = '';
+ dispatch(attachmentsTd, 'click');
await Promise.resolve();
- await Promise.resolve();
- assert.equal(
- clipboardText,
- 'hunter2',
- 'still the last real copy — the empty cell copied nothing',
- );
-
- // Clicking the same row twice before the delay elapses reschedules rather
- // than opening the entry twice.
- const row2 = q('.entry-table tbody tr') as HTMLElement;
- dispatch(row2, 'click');
- dispatch(row2, 'click');
- t.mock.timers.tick(250);
- assert.ok(q('#detail-title')?.textContent?.includes('GitHub'));
- dispatch(q('[data-action="back"]'), 'click');
+ assert.equal(clipboardText, '', 'a cell with nothing in it copies nothing');
dispatch(q('[data-action="view-tile"]'), 'click');
assert.equal(root().querySelectorAll('.entry-row').length, 2);