Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/web/e2e/keyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
});
31 changes: 30 additions & 1 deletion apps/web/src/app/shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
Expand Down Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions apps/web/src/app/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 4 additions & 2 deletions apps/web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
40 changes: 31 additions & 9 deletions apps/web/src/ui/Generator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type CharacterSetName,
GENERATOR_LIMITS,
generatePassphrase,
generatePassword,
passphraseEntropyBits,
Expand Down Expand Up @@ -64,15 +65,36 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void }
</div>
{settings.mode === 'characters' ? (
<>
<Field
label={m.generator.length}
inputMode="numeric"
value={length}
onChange={(e) => {
setLength(e.target.value);
update({ ...settings, length: Number(e.target.value) });
}}
/>
{/* The slider moves the same length as the field beside it (D251). */}
<div className="flex items-end gap-3">
<div className="w-32">
<Field
label={m.generator.length}
inputMode="numeric"
value={length}
onChange={(e) => {
setLength(e.target.value);
update({ ...settings, length: Number(e.target.value) });
}}
/>
</div>
<input
type="range"
// As every control of the app: nothing for the form history or a spellchecker (D174, D176).
autoComplete="off"
spellCheck={false}
aria-label={m.generator.length}
min={GENERATOR_LIMITS.characters.min}
max={GENERATOR_LIMITS.characters.max}
value={settings.length}
onChange={(e) => {
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"
/>
</div>
<div className="flex flex-wrap gap-4 text-sm">
{SETS.map((set) => (
<label key={set} className={`${TARGET} flex items-center gap-2`}>
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/ui/ItemEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
newDraft,
} from '../app/item-draft';
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';
Expand Down Expand Up @@ -270,6 +271,12 @@ export function ItemEditor({
ref={form}
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;
e.preventDefault();
form.current?.requestSubmit();
}}
onSubmit={(e) => {
e.preventDefault();
const result = inputOf(draft);
Expand Down Expand Up @@ -418,7 +425,12 @@ export function ItemEditor({
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" disabled={saving}>
<Button
type="submit"
disabled={saving}
title={`${m.items.save} (Ctrl/⌘ Enter)`}
aria-keyshortcuts="Control+Enter Meta+Enter"
>
{saving ? m.items.saving : m.items.save}
</Button>
<Button
Expand Down
36 changes: 34 additions & 2 deletions apps/web/src/ui/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { type Item, type TreeItem, type VaultSession } from '@blindvault/vault';
import { type RefObject, useState, useSyncExternalStore } from 'react';
import { type RefObject, useRef, useState, useSyncExternalStore } from 'react';

import { findNode, treeItems } from '../app/folders';
import { hex } from '../app/ids';
import { type Navigation } from '../app/nav';
import { letter } from '../app/row';
import { type SearchIndex } from '../app/search';
import { listMove } from '../app/shortcuts';
import { useLanguage } from '../i18n/context';
import { type Messages } from '../i18n/messages';
import { Button, INPUT, ROW, TARGET } from './components';
Expand Down Expand Up @@ -55,6 +56,9 @@ export function ItemList({
const { copy } = useCopy(session.preferences.clipboardClearSeconds);
const screen = useSyncExternalStore(nav.subscribe, nav.current);
const list = useSyncExternalStore(nav.subscribe, nav.list);
const rowsRef = useRef<HTMLUListElement>(null);
// The items' own buttons, in the order shown; a row's Copy is reached with Tab.
const rows = () => [...(rowsRef.current?.querySelectorAll<HTMLElement>('[data-row]') ?? [])];
const collator = new Intl.Collator(language);
const tree = session.tree(collator.compare);
const named = (t: TreeItem): Row => ({
Expand Down Expand Up @@ -162,6 +166,18 @@ export function ItemList({
onChange={(e) => {
onQuery(e.target.value);
}}
onKeyDown={(e) => {
// ↓ moves into the list; Esc clears the search, in every browser (D251).
if (e.key === 'ArrowDown' && !e.altKey) {
const first = rows()[0];
if (!first) return;
e.preventDefault();
first.focus();
} else if (e.key === 'Escape' && query !== '') {
e.preventDefault();
onQuery('');
}
}}
data-testid="search"
/>
) : null}
Expand Down Expand Up @@ -234,7 +250,23 @@ export function ItemList({
</ul>
) : null}
{items.length > 0 ? (
<ul className="flex flex-col">
<ul
ref={rowsRef}
className="flex flex-col"
onKeyDown={(e) => {
// ↑/↓ and J/K move between the rows; above the first is the search (D251).
const move = listMove(e);
const { target } = e;
if (move === undefined || !(target instanceof HTMLElement)) return;
const all = rows();
const at = all.findIndex((row) => row.closest('li') === target.closest('li'));
if (at < 0) return;
e.preventDefault();
const next = at + move;
if (next < 0) searchRef.current?.focus();
else all[next]?.focus();
}}
>
{items.flatMap((row, i) => {
const id = hex(row.id);
const group = letter(row.name);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/ui/ItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function ItemRow({
<button
type="button"
aria-current={selected ? 'true' : undefined}
data-row
aria-describedby={sub ? id : undefined}
className={`${TARGET} flex min-w-0 flex-1 items-center gap-3 rounded-control px-2 py-1.5 text-left text-sm`}
onClick={onSelect}
Expand Down
1 change: 1 addition & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -1011,3 +1011,4 @@ This document is **normative**.
| D248 | TOTP ring | The fourth area of Phase 7b (D244). Beside a TOTP code, a ring shows the seconds left in its period: the share of the period left, in the accent color, amber (`warning`) from 10 s and red (`danger`) from 5 s, on a `line` track; each color is in the contrast test at 3:1 as a graphic. It steps once a second, when the code's clock does, with no transition or animation, so it is not motion under D236 and stays the same under `prefers-reduced-motion`. It is decorative (`aria-hidden`): the seconds stay in text beside the code ("Changes in N s"), so color is never the only signal. |
| D249 | Item password strength | The fifth area of Phase 7b (D244). **What is rated.** A template's `password` field of type `secret`, the one secret whose use is known; a PIN, a card's security code, a TOTP secret, a seed phrase and the user's own secret fields are not rated, since a score for them would mislead. **How.** zxcvbn with the dictionaries of D71, already loaded before the first unlock (D191), run in the page on the value in memory; nothing is stored or sent. Scores 0 to 2 are Weak, 3 Fair, 4 Strong. **Editor.** Under the password, a meter of four bars, seen only (`aria-hidden`), and the field's hint, its accessible description: "Strength: <level>.", then zxcvbn's warning or a fixed advice below Strong. **Detail.** A Weak badge beside the password's name, only when weak; nothing for Fair or Strong. **Never blocks.** Unlike the local and export passwords (D71, D134), a weak item password is saved like any other: the site sets its rules, and the user may hold a password they cannot change. **Exposure.** The badge says a password is weak to someone who sees the screen, as the masked value does not; the detail is already shown only unlocked (section 13.2), and the list row shows no rating (CRYPTO-INV-024). |
| D250 | Feedback | The sixth area of Phase 7b (D244). **Toast.** One region of the app's own, a `role=status` mounted with the vault shell, so each toast is announced and none outlives a lock; at the bottom, above the phone's navigation, never taking the pointer. A new toast replaces the last, and each goes after 5 s. It says that an item was saved, moved to the trash or restored, and what a copy said beside its button before: copied, with the clearing that is not guaranteed (D54, D127), or that the browser refused; the check of D231 stays on the button. A toast holds no secret. Its entrance is motion under D236, none under `prefers-reduced-motion`. **Bottom sheet.** On a phone the confirmation of D231 is a bottom sheet: the same native modal `<dialog>`, with its focus, Escape and inert page, placed at the bottom at full width, its buttons full width and 44 px tall, the action first; wider, the centered dialog as before. No drag handle: nothing is dragged. No confirmation is added: moving an item to the trash stays one action, since the trash restores it. **First steps.** All items, with no item outside the trash and no search, shows first steps in place of "No items yet.": the vault made, marked done; add an item, opening the template picker; import, opening Settings at the import; pair a device, opening the pairing as "Vault ready" does (D198). A vault that cannot be written leaves out the item and the import. **Error boundary.** Around every screen, a render error locks the vault (the session's keys and plaintext, as on any lock) and clears the clipboard (D54) in the commit, before the browser paints its failure screen; the screens that held plaintext are already unmounted. The screen shows nothing of the vault: what happened, that what was saved stays on the device, and Reload. Errors outside a render (an event handler, a promise) are handled where they occur, as before. |
| D251 | Keyboard and length slider | The last area of Phase 7b (D244); keys, like D197, work in every layout. **List.** With the focus on an item of the list, ↓ or J moves it to the next item and ↑ or K to the one before, with no modifier; past the last item it stays, and above the first it goes to the search. They move the focus only: Enter opens the item, so a key never shows an item the user did not choose. A row's Copy is reached with Tab, as before. **Search.** ↓ moves the focus to the first item shown; Esc clears a non-empty search, the same in every browser (Firefox leaves `type=search` as typed), and keeps the focus in it; an empty search ignores Esc. The search's hint says both. **Editor.** Ctrl/⌘ + Enter submits the editor from any of its fields, the notes included, where Enter alone is a new line; never while the discard dialog is open or a save runs. The save goes through the same validation as Save. **Declared.** `aria-keyshortcuts` names the keys that activate or focus a control: Save carries Ctrl/⌘ + Enter and the search keeps Ctrl/⌘ + K. The movement keys are not declared on each row, where a screen reader would read them on every item; the search's hint says them instead. **Generator.** A range slider beside the length field, from 12 to 128 (D128), named as the field; either sets the same length, and the field keeps typed values the slider cannot show. |
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau
- [x] TOTP ring, stepping once a second with no transition (D248)
- [x] Password strength on item secrets: meter in the editor, Weak badge on the detail, never blocking a save (D249)
- [x] Feedback: toast region, bottom sheet on a phone, empty-vault checklist, error boundary that locks the vault (D250)
- [ ] Keyboard: list movement, search to list, Esc, Ctrl/⌘+Enter; generator length slider
- [x] Keyboard: list movement, search to list, Esc, Ctrl/⌘+Enter; generator length slider (D251)
- [ ] Release v0.7.0 for another hands-on test

### Essential UX Gate
Expand Down
Loading