diff --git a/apps/web/e2e/keyboard.spec.ts b/apps/web/e2e/keyboard.spec.ts index ef2546c..1254d79 100644 --- a/apps/web/e2e/keyboard.spec.ts +++ b/apps/web/e2e/keyboard.spec.ts @@ -5,6 +5,7 @@ import { type Locator, type Page, expect, test } from '@playwright/test'; import { WIDTHS } from './axe'; +import { createVault } from './create'; // Creating a vault calibrates Argon2id at about 1 s (D9), and the unlock runs it again. test.setTimeout(180_000); @@ -125,3 +126,70 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); }); } + +test('the list moves with the arrows and J/K, the search clears with Esc, Ctrl/⌘+Enter saves and the slider sets the length (D251)', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/'); + await createVault(page, PASSWORD); + + // Ctrl/⌘ + Enter saves from the notes, where Enter alone is a new line. + for (const title of ['Charlie', 'Alpha', 'Bravo']) { + await button(page, 'New item').click(); + await button(page, 'Login').click(); + await page.getByTestId('item-title').fill(title); + await page.getByTestId('item-notes').fill('note'); + await page.getByTestId('item-notes').press('ControlOrMeta+Enter'); + await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible(); + } + await expect(button(page, 'Save')).toHaveCount(0); + + const search = page.getByTestId('search'); + const row = (name: string) => button(page, name); + await page.keyboard.press('ControlOrMeta+k'); + await expect(search).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(row('Alpha')).toBeFocused(); + await page.keyboard.press('j'); + await expect(row('Bravo')).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(row('Charlie')).toBeFocused(); + // Past the last row, the focus stays. + await page.keyboard.press('ArrowDown'); + await expect(row('Charlie')).toBeFocused(); + await page.keyboard.press('k'); + await expect(row('Bravo')).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('heading', { name: 'Bravo', level: 1 })).toBeVisible(); + await row('Bravo').focus(); + await page.keyboard.press('ArrowUp'); + await expect(row('Alpha')).toBeFocused(); + // Above the first row is the search. + await page.keyboard.press('ArrowUp'); + await expect(search).toBeFocused(); + + // Esc clears the search and keeps the focus in it. + await page.keyboard.type('zzz'); + await expect(page.getByText('No items match.')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(search).toHaveValue(''); + await expect(search).toBeFocused(); + await expect(row('Alpha')).toBeVisible(); + + // The slider and the field set the same length. + await button(page, 'New item').click(); + await button(page, 'Login').click(); + await button(page, 'Generate').click(); + const slider = page.getByTestId('generator-length-slider'); + const field = page.getByRole('textbox', { name: 'Length (12–128)' }); + await expect(field).toHaveValue('20'); + await slider.focus(); + await page.keyboard.press('ArrowRight'); + await expect(field).toHaveValue('21'); + await page.keyboard.press('End'); + await expect(field).toHaveValue('128'); + await field.fill('40'); + await expect(slider).toHaveValue('40'); + await expect(page.getByTestId('generator-entropy')).toHaveText('262 bits of entropy'); +}); diff --git a/apps/web/src/app/shortcuts.test.ts b/apps/web/src/app/shortcuts.test.ts index 87172c4..4620f59 100644 --- a/apps/web/src/app/shortcuts.test.ts +++ b/apps/web/src/app/shortcuts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { type ShortcutKey, newItemShortcut } from './shortcuts'; +import { type ShortcutKey, listMove, newItemShortcut, saveShortcut } from './shortcuts'; import { type Screen } from './nav'; const ID = new Uint8Array([1]); @@ -62,3 +62,32 @@ describe('the new item shortcut (D197)', () => { expect(newItemShortcut(KEY, { ...READY, writable: false })).toBe(false); }); }); + +describe('the list movement keys (D251)', () => { + it('moves down with ↓ or J and up with ↑ or K, with or without caps lock', () => { + for (const key of ['ArrowDown', 'j', 'J']) expect(listMove({ ...KEY, key }), key).toBe(1); + for (const key of ['ArrowUp', 'k', 'K']) expect(listMove({ ...KEY, key }), key).toBe(-1); + }); + + it('ignores a modifier and every other key', () => { + for (const modifier of ['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const) { + expect(listMove({ ...KEY, key: 'j', [modifier]: true }), modifier).toBeUndefined(); + } + for (const key of ['n', 'Enter', 'ArrowLeft', ' ']) + expect(listMove({ ...KEY, key })).toBeUndefined(); + }); +}); + +describe('the save shortcut (D251)', () => { + it('is Ctrl + Enter or ⌘ + Enter', () => { + expect(saveShortcut({ ...KEY, key: 'Enter', ctrlKey: true })).toBe(true); + expect(saveShortcut({ ...KEY, key: 'Enter', metaKey: true })).toBe(true); + }); + + it('is not Enter alone, with Shift or Alt, nor another key', () => { + expect(saveShortcut({ ...KEY, key: 'Enter' })).toBe(false); + expect(saveShortcut({ ...KEY, key: 'Enter', ctrlKey: true, shiftKey: true })).toBe(false); + expect(saveShortcut({ ...KEY, key: 'Enter', metaKey: true, altKey: true })).toBe(false); + expect(saveShortcut({ ...KEY, key: 's', ctrlKey: true })).toBe(false); + }); +}); diff --git a/apps/web/src/app/shortcuts.ts b/apps/web/src/app/shortcuts.ts index 439e45e..dd36276 100644 --- a/apps/web/src/app/shortcuts.ts +++ b/apps/web/src/app/shortcuts.ts @@ -32,3 +32,16 @@ export function newItemShortcut( screen.name === 'history'; return !context.editing && from && context.list.name !== 'trash' && context.writable; } + +/** ↓ or J moves to the next row of the list, ↑ or K to the one before (D251); no modifier. */ +export function listMove(e: ShortcutKey): 1 | -1 | undefined { + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return undefined; + if (e.key === 'ArrowDown' || e.key.toLowerCase() === 'j') return 1; + if (e.key === 'ArrowUp' || e.key.toLowerCase() === 'k') return -1; + return undefined; +} + +/** Ctrl/⌘ + Enter saves the item editor (D251). */ +export function saveShortcut(e: ShortcutKey): boolean { + return e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey; +} diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index b400649..f481658 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -615,7 +615,8 @@ const en = { trash: 'Trash', trashEmpty: 'The trash is empty.', search: 'Search', - searchHint: 'Searches titles, tags, folders, notes and fields that are not secret.', + searchHint: + 'Searches titles, tags, folders, notes and fields that are not secret. ↓ moves to the list; Esc clears.', results: 'Results', noResults: 'No items match.', filterType: 'Filter by type', @@ -1671,7 +1672,8 @@ const ptBR: Messages = { trash: 'Lixeira', trashEmpty: 'A lixeira está vazia.', search: 'Buscar', - searchHint: 'Busca em títulos, tags, pastas, notas e campos que não são secretos.', + searchHint: + 'Busca em títulos, tags, pastas, notas e campos que não são secretos. ↓ leva à lista; Esc limpa.', results: 'Resultados', noResults: 'Nenhum item corresponde.', filterType: 'Filtrar por tipo', diff --git a/apps/web/src/ui/Generator.tsx b/apps/web/src/ui/Generator.tsx index b13c1be..1abf0a3 100644 --- a/apps/web/src/ui/Generator.tsx +++ b/apps/web/src/ui/Generator.tsx @@ -1,5 +1,6 @@ import { type CharacterSetName, + GENERATOR_LIMITS, generatePassphrase, generatePassword, passphraseEntropyBits, @@ -64,15 +65,36 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void } {settings.mode === 'characters' ? ( <> - { - setLength(e.target.value); - update({ ...settings, length: Number(e.target.value) }); - }} - /> + {/* The slider moves the same length as the field beside it (D251). */} +
+
+ { + setLength(e.target.value); + update({ ...settings, length: Number(e.target.value) }); + }} + /> +
+ { + setLength(e.target.value); + update({ ...settings, length: Number(e.target.value) }); + }} + className={`${TARGET} min-w-0 flex-1 accent-accent`} + data-testid="generator-length-slider" + /> +
{SETS.map((set) => (