diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts index e166c67..a1ff99b 100644 --- a/apps/web/e2e/accessibility.spec.ts +++ b/apps/web/e2e/accessibility.spec.ts @@ -20,6 +20,7 @@ test('names the generator, describes a field by its error, and reads a mask and // The generator is a named group. await page.getByRole('button', { name: 'Generate', exact: true }).click(); await expect(page.getByRole('group', { name: 'Password generator' })).toBeVisible(); + await page.keyboard.press('Escape'); // A refused value: the field's error is its description. await page.getByTestId('item-title').fill('ACME'); @@ -57,6 +58,7 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti await page.getByRole('button', { name: 'Login', exact: true }).click(); await page.getByRole('button', { name: 'Generate', exact: true }).click(); expect(await fits()).toBe(true); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Settings' }).first().click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts index e6006b4..67c217f 100644 --- a/apps/web/e2e/axe.spec.ts +++ b/apps/web/e2e/axe.spec.ts @@ -66,6 +66,7 @@ for (const [layout, width] of WIDTHS) { await button(page, 'Login').click(); await button(page, 'Generate').click(); await audit(page, 'the item editor and the generator'); + await page.keyboard.press('Escape'); await button(page, 'Save').click(); await expect(page.getByText('Enter a title.')).toBeVisible(); await audit(page, 'the item editor with an error'); @@ -79,7 +80,6 @@ for (const [layout, width] of WIDTHS) { await button(page, 'Cancel').click(); await audit(page, 'the discard confirmation'); await button(page, 'Discard the changes').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); await audit(page, 'an item history'); diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts index 7b852eb..d716ec1 100644 --- a/apps/web/e2e/backup-merge.spec.ts +++ b/apps/web/e2e/backup-merge.spec.ts @@ -131,7 +131,6 @@ test('merges a backup made on another installation, keeping the losing edit in h await button(a, 'ACME').click(); await button(a, 'Show').click(); await expect(a.getByTestId('secret-Password')).toHaveText(ON_B); - await button(a, 'More actions').click(); await button(a, 'History').click(); const versions = a.locator('article').getByRole('listitem'); await expect(versions).toHaveCount(2); diff --git a/apps/web/e2e/duplicate.spec.ts b/apps/web/e2e/duplicate.spec.ts index 01f7e7f..d36a4c1 100644 --- a/apps/web/e2e/duplicate.spec.ts +++ b/apps/web/e2e/duplicate.spec.ts @@ -42,7 +42,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible(); // The history says old values stay, and keeps them masked. - await button(page, 'More actions').click(); await button(page, 'History').click(); await expect(page.getByRole('heading', { name: 'History of Harbor' })).toBeVisible(); await expect(page.getByText(/Earlier values stay in this history/)).toBeVisible(); @@ -65,7 +64,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByText('Work', { exact: true }).last()).toBeVisible(); await button(page, 'Show').click(); await expect(page.getByTestId('secret-Password')).toHaveText(OLD); - await button(page, 'More actions').click(); await button(page, 'History').click(); await expect(page.getByText('No earlier versions.')).toBeVisible(); await button(page, 'Back to the item').click(); diff --git a/apps/web/e2e/form-history.spec.ts b/apps/web/e2e/form-history.spec.ts index b959f22..2cf6460 100644 --- a/apps/web/e2e/form-history.spec.ts +++ b/apps/web/e2e/form-history.spec.ts @@ -118,6 +118,7 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1 } await button(page, 'Generate').first().click(); await expectNoneKept(page); + await page.keyboard.press('Escape'); await button(page, 'Cancel').click(); // The added fields are a change: leaving asks first (D234). await button(page, 'Discard the changes').click(); diff --git a/apps/web/e2e/item-detail.spec.ts b/apps/web/e2e/item-detail.spec.ts index f46ea79..0345710 100644 --- a/apps/web/e2e/item-detail.spec.ts +++ b/apps/web/e2e/item-detail.spec.ts @@ -3,8 +3,8 @@ import { type Page, expect, test } from '@playwright/test'; import { createVault } from './create'; // The item detail (D247): a header with the avatar and chips, fields in titled cards, icon and text -// on a secret's buttons, Duplicate, History and Move to trash under "More actions", and a shown -// seed phrase as a numbered grid. +// on a secret's buttons and icon buttons on the others (D254), Duplicate, History and Move to trash +// under "More actions", and a shown seed phrase as a numbered grid. test.setTimeout(120_000); @@ -57,16 +57,27 @@ test('the detail shows chips, titled cards and the actions menu (D247)', async ( // A secret's buttons carry an icon beside their visible text. await expect(button(page, 'Show').locator('svg')).toHaveCount(1); await expect(button(page, 'Copy: Password').locator('svg')).toHaveCount(1); + // Other fields: Copy and Open are icon buttons, named and with a tooltip (D254). + const copyUser = button(page, 'Copy: Username'); + await expect(copyUser).toHaveAttribute('title', 'Copy'); + await expect(copyUser).toHaveText(''); + await expect(page.getByRole('link', { name: 'Open: Website', exact: true })).toHaveAttribute( + 'href', + 'https://bank.example/login', + ); // Edit is visible; the rest waits under More actions. await expect(button(page, 'Edit')).toBeVisible(); await expect(button(page, 'Duplicate')).toHaveCount(0); await button(page, 'More actions').click(); + const menu = page.locator(':popover-open'); for (const name of ['Duplicate', 'History', 'Move to trash']) { - await expect(button(page, name)).toBeVisible(); + await expect(menu.getByRole('button', { name, exact: true })).toBeVisible(); } await page.keyboard.press('Escape'); - await expect(button(page, 'History')).toHaveCount(0); + await expect(button(page, 'Duplicate')).toHaveCount(0); + // History is also a link at the detail's foot (D254). + await expect(button(page, 'History')).toBeVisible(); }); test('a shown seed phrase is a numbered grid of its words (D247)', async ({ page }) => { diff --git a/apps/web/e2e/keyboard.spec.ts b/apps/web/e2e/keyboard.spec.ts index 1254d79..74b40f1 100644 --- a/apps/web/e2e/keyboard.spec.ts +++ b/apps/web/e2e/keyboard.spec.ts @@ -109,7 +109,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByTestId('discard-confirm')).toBeVisible(); await press(page, button(page, 'Discard the changes')); await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible(); - await press(page, button(page, 'More actions')); await press(page, button(page, 'History')); await expect(page.getByRole('heading', { name: 'History' }).first()).toBeVisible(); diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts index 110ae9d..18df7dd 100644 --- a/apps/web/e2e/layout.spec.ts +++ b/apps/web/e2e/layout.spec.ts @@ -353,6 +353,7 @@ test.describe('on a coarse pointer', () => { await page.getByRole('button', { name: 'Generate', exact: true }).click(); await expect(page.getByTestId('generator-entropy')).toBeVisible(); expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); diff --git a/apps/web/e2e/logs.spec.ts b/apps/web/e2e/logs.spec.ts index 8960c36..5a5acee 100644 --- a/apps/web/e2e/logs.spec.ts +++ b/apps/web/e2e/logs.spec.ts @@ -57,7 +57,6 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as await page.getByRole('button', { name: 'Edit', exact: true }).click(); await page.getByTestId('input-Password').fill(ROTATED); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await page.getByRole('button', { name: 'More actions', exact: true }).click(); await page.getByRole('button', { name: 'History', exact: true }).click(); await page.getByRole('button', { name: 'Show', exact: true }).first().click(); await expect(page.getByText(SECRET)).toBeVisible(); diff --git a/apps/web/e2e/screen-reader.spec.ts b/apps/web/e2e/screen-reader.spec.ts index 7854356..ffc54c5 100644 --- a/apps/web/e2e/screen-reader.spec.ts +++ b/apps/web/e2e/screen-reader.spec.ts @@ -132,8 +132,8 @@ for (const [layout, width] of WIDTHS) { - button "Show" - button "Generate" - alert: Fix the fields marked above. - - button "Save" - button "Cancel" + - button "Save" `); // An item: the secret is read as "Hidden", and a copy is announced with its clipboard note. diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts index 86e4b94..be4a0e2 100644 --- a/apps/web/e2e/sync.spec.ts +++ b/apps/web/e2e/sync.spec.ts @@ -440,7 +440,6 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i for (const page of [a, b]) { await expect(button(page, 'ACME on A')).toHaveCount(0); await button(page, 'ACME on B').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); // A's edit lost on both, and both keep it, above the first state. const versions = page.locator('article').getByRole('listitem'); @@ -478,7 +477,6 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and await closed(a, b); for (const page of [a, b]) { await button(page, title).click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); const versions = page.locator('article').getByRole('listitem'); await expect(versions).toHaveCount(20); @@ -516,7 +514,6 @@ test('an edit older than a trash never brings the item back: in the trash on bot await expect(page.getByText('The trash is empty.')).toBeVisible(); await button(page, 'All items').click(); await button(page, 'ACME').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); const versions = page.locator('article').getByRole('listitem'); await expect(versions.filter({ hasText: 'ACME on A' })).toHaveCount(1); diff --git a/apps/web/e2e/write-navigation.spec.ts b/apps/web/e2e/write-navigation.spec.ts index d5c4c19..1fbd865 100644 --- a/apps/web/e2e/write-navigation.spec.ts +++ b/apps/web/e2e/write-navigation.spec.ts @@ -77,7 +77,6 @@ test('another item chosen while a version is restored stays shown (D210)', async await page.getByTestId('item-title').fill('ACME 2'); await button(page, 'Save').click(); await expect(heading(page, 'ACME 2', 1)).toBeVisible(); - await button(page, 'More actions').click(); await button(page, 'History').click(); await together(button(page, 'Restore this version'), button(page, 'Beta')); await expect(button(page, 'ACME')).toBeVisible(); diff --git a/apps/web/src/ui/Generator.tsx b/apps/web/src/ui/Generator.tsx index 1abf0a3..f33d392 100644 --- a/apps/web/src/ui/Generator.tsx +++ b/apps/web/src/ui/Generator.tsx @@ -34,7 +34,13 @@ function entropy(s: GeneratorSettings): number | null { * The password generator of the item editor (overview §13.6). The generated value goes straight * into the field being edited; only the settings are kept. */ -export function Generator({ onUse }: { readonly onUse: (value: string) => void }) { +export function Generator({ + onUse, + onCancel, +}: { + readonly onUse: (value: string) => void; + readonly onCancel: () => void; +}) { const m = useMessages(); const [settings, setSettings] = useState(loadGeneratorSettings); // Counts typed as text, so a value can be typed through an invalid one. @@ -46,8 +52,8 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void } }; const bits = entropy(settings); return ( -
- {m.generator.title} +
+ {m.generator.title}
{(['characters', 'words'] as const).map((mode) => (
); } diff --git a/apps/web/src/ui/ItemDetail.tsx b/apps/web/src/ui/ItemDetail.tsx index 8d68543..bf1337d 100644 --- a/apps/web/src/ui/ItemDetail.tsx +++ b/apps/web/src/ui/ItemDetail.tsx @@ -11,11 +11,11 @@ import { type Navigation } from '../app/nav'; import { host } from '../app/row'; import { useLanguage } from '../i18n/context'; import { type Messages, format, roleName } from '../i18n/messages'; -import { Button, Notice, Overflow } from './components'; +import { Button, Notice, Overflow, TARGET } from './components'; import { writeError } from './errors'; import { Icon, type IconName } from './icons'; import { Avatar, TEMPLATE_ICON, known } from './ItemRow'; -import { SecretView } from './Secret'; +import { SecretView, useCopy } from './Secret'; import { WeakBadge, rated } from './Strength'; import { useToast } from './Toast'; import { TotpView } from './Totp'; @@ -40,6 +40,9 @@ function openable(value: string): boolean { } } +/** An icon button of a field that is not secret: named by its action and the field (D254). */ +const ICON_BUTTON = `${TARGET} inline-flex min-w-10 shrink-0 items-center justify-center rounded-control text-fg hover:bg-subtle`; + export function FieldValue({ field, name, @@ -50,6 +53,7 @@ export function FieldValue({ readonly clearSeconds: number; }) { const m = useLanguage().messages; + const { copy, checked } = useCopy(clearSeconds); const { value } = field; if (typeof value !== 'string') { return ; @@ -66,25 +70,39 @@ export function FieldValue({ )} ); - case 'url': + default: return ( - - {value} - {openable(value) ? ( + + {value} + {field.type === 'url' && openable(value) ? ( - {m.items.open} + ) : null} + ); - default: - return {value}; } } @@ -251,7 +269,6 @@ export function ItemDetail({ ) : ( + ) : null} {error ? (

{error} diff --git a/apps/web/src/ui/ItemEditor.tsx b/apps/web/src/ui/ItemEditor.tsx index 39d9f6b..13989c0 100644 --- a/apps/web/src/ui/ItemEditor.tsx +++ b/apps/web/src/ui/ItemEditor.tsx @@ -25,9 +25,10 @@ import { type Navigation } from '../app/nav'; import { saveShortcut } from '../app/shortcuts'; import { useLanguage, useMessages } from '../i18n/context'; import { type Messages, roleName } from '../i18n/messages'; -import { Button, ConfirmDialog, Field, INPUT, Notice, TextArea } from './components'; +import { Button, ConfirmDialog, Field, INPUT, Modal, Notice, TextArea } from './components'; import { writeError } from './errors'; import { Generator } from './Generator'; +import { Icon } from './icons'; import { SecretInput } from './Secret'; import { rated, useMeter } from './Strength'; import { useToast } from './Toast'; @@ -93,6 +94,20 @@ function FieldEditor({ hint={hint} shown={shown} onShownChange={setShown} + action={ + field.type === 'secret' ? ( + + ) : null + } /> ); } else if (field.type === 'long-text') { @@ -146,27 +161,24 @@ function FieldEditor({ ) : null} {input} {meter} - {field.type === 'secret' ? ( -

- - {generating ? ( - { - set(value); - setGenerating(false); - }} - /> - ) : null} -
+ onCancel={() => { + setGenerating(false); + }} + /> + ) : null} ); @@ -272,8 +284,9 @@ export function ItemEditor({ noValidate className="flex w-full max-w-2xl flex-col gap-4 p-4" onKeyDown={(e) => { - // Ctrl/⌘ + Enter saves from any field, the notes included; not under the dialog (D251). - if (!saveShortcut(e) || leaving || saving) return; + // Ctrl/⌘ + Enter saves from any field, the notes included; not from a dialog (D251). + const inDialog = e.target instanceof Element && e.target.closest('dialog') !== null; + if (!saveShortcut(e) || leaving || saving || inDialog) return; e.preventDefault(); form.current?.requestSubmit(); }} @@ -424,7 +437,16 @@ export function ItemEditor({ {failure}

) : null} -
+ {/* Fixed at the bottom of the pane, so Save never scrolls away (D254). */} +
+ -
{leaving ? ( void; + /** A button after Show, as the generator's (D254). */ + readonly action?: ReactNode; }) { const m = useMessages(); const id = useId(); @@ -211,6 +214,7 @@ export function SecretInput({ setShown(!shown); }} /> + {action}
void; }) { const m = useMessages(); + // On a phone the whole screen scrolls, so the editor's bar sticks to it (D254). + const scrolls = useLayout() !== 'mobile'; const screen = useSyncExternalStore(nav.subscribe, nav.current); if (!isDetail(screen)) { return ( @@ -59,7 +61,10 @@ function DetailPane({ ? `new-${String(nav.opening(screen))}` : `${screen.name}-${hex(screen.id)}`; return ( -
+
{screen.name === 'item' ? ( ) : screen.name === 'history' ? ( diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx index f6b263d..9ec1e03 100644 --- a/apps/web/src/ui/components.tsx +++ b/apps/web/src/ui/components.tsx @@ -111,28 +111,14 @@ export function CopyCheck({ shown }: { readonly shown: boolean }) { } /** - * A destructive or security confirmation in the native modal dialog (D231, D233): the focus stays - * inside, Escape cancels and the rest of the page is inert. Shown while mounted; its text is the - * caller's, unchanged from the inline confirmation it replaces. On a phone the same dialog is a - * bottom sheet, its buttons full width with the action first (D250). + * The native modal dialog (D231, D233): the focus stays inside, Escape calls `onCancel` and the rest + * of the page is inert. Shown while mounted. On a phone it is a bottom sheet (D250). */ -export function ConfirmDialog({ - confirm, - cancel, - onConfirm, +export function Modal({ onCancel, - busy = false, - tone = 'danger', children, ...props -}: Omit, 'onCancel'> & { - readonly confirm: string; - readonly cancel: string; - readonly onConfirm: () => void; - readonly onCancel: () => void; - readonly busy?: boolean; - readonly tone?: 'danger' | 'primary'; -}) { +}: Omit, 'onCancel'> & { readonly onCancel: () => void }) { const ref = useRef(null); const sheet = useLayout() === 'mobile'; useEffect(() => { @@ -145,7 +131,6 @@ export function ConfirmDialog({ return ( { - // Escape: the caller unmounts the dialog, unless the work already started. + // Escape: the caller unmounts the dialog. e.preventDefault(); + onCancel(); + }} + {...props} + > + {children} + + ); +} + +/** + * A destructive or security confirmation in a `Modal`; its text is the caller's, unchanged from the + * inline confirmation it replaces. On a phone its buttons are full width with the action first. + */ +export function ConfirmDialog({ + confirm, + cancel, + onConfirm, + onCancel, + busy = false, + tone = 'danger', + children, + ...props +}: Omit, 'onCancel'> & { + readonly confirm: string; + readonly cancel: string; + readonly onConfirm: () => void; + readonly onCancel: () => void; + readonly busy?: boolean; + readonly tone?: 'danger' | 'primary'; +}) { + const sheet = useLayout() === 'mobile'; + return ( + { + // Not once the work started. if (!busy) onCancel(); }} {...props} @@ -181,7 +202,7 @@ export function ConfirmDialog({ )} - + ); } diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx index f8c64ff..278a43b 100644 --- a/apps/web/src/ui/icons/index.tsx +++ b/apps/web/src/ui/icons/index.tsx @@ -38,6 +38,16 @@ const PATHS = { ), + dices: ( + <> + + + + + + + + ), download: ( <> @@ -52,6 +62,13 @@ const PATHS = { ), + 'external-link': ( + <> + + + + + ), eye: ( <> diff --git a/docs/roadmap.md b/docs/roadmap.md index 5413e48..6ffd717 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -435,7 +435,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau - [x] Capture every v0.7.1 screen and approve an HTML prototype of the layout and presentation changes (`docs/prototypes/phase-7b-layout.html`, D254) - [x] Notices: the Preview badge, the storage warning on one line with Details, the backup reminder at the top of the list, a neutral lock badge (D254) - [x] Lists: folders and types as pills opening a bottom sheet on a phone, the toast above the bottom navigation (D254) -- [ ] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254) +- [x] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254) - [ ] Show and Hide on every password field, masked again on submit, on a screen change and on lock (D254) - [ ] Settings inside the vault frame on tablet and desktop; Devices with the Pair and Re-admit cards (D254) - [ ] Wizards on a screen of their own: "Next:", Cancel's place, the fixed action on a phone, the file drop area with Continue (D254)